TEZv commited on
Commit
ecf207c
·
1 Parent(s): 177b9af

Add baseline share and source anchors

Browse files
Files changed (3) hide show
  1. app.py +72 -8
  2. src/assumptions.py +59 -0
  3. src/model_pool.py +7 -1
app.py CHANGED
@@ -4,7 +4,14 @@ import pandas as pd
4
  import plotly.express as px
5
  import streamlit as st
6
 
7
- from src.assumptions import BASELINE, DATA_QUALITY_NOTES
 
 
 
 
 
 
 
8
  from src.model_pool import Criteria, estimate_pool, sensitivity_table
9
 
10
 
@@ -23,6 +30,22 @@ def title_label(value: str) -> str:
23
  return value.replace("_", " ").title()
24
 
25
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  st.set_page_config(
27
  page_title="Partner Pool Assumption Simulator",
28
  page_icon="S7",
@@ -40,10 +63,23 @@ st.info(
40
  with st.sidebar:
41
  st.header("Scenario")
42
  with st.expander("Core demographics", expanded=True):
 
 
 
 
 
 
 
 
 
 
43
  base_population_text = st.text_input(
44
  "Baseline population",
45
- value=format_count(BASELINE.total_reference_population),
46
- help=f"Reference population before filters, formatted with commas. Demo default: {format_count(BASELINE.total_reference_population)}.",
 
 
 
47
  )
48
  try:
49
  base_population = parse_count(base_population_text, BASELINE.total_reference_population)
@@ -89,8 +125,13 @@ with st.sidebar:
89
  income_level = st.selectbox(
90
  "Income threshold",
91
  ["any", "above_median", "top_25", "top_10"],
92
- format_func=title_label,
93
- help="Applies an estimated income threshold coefficient.",
 
 
 
 
 
94
  )
95
  education_level = st.selectbox(
96
  "Education filter",
@@ -182,29 +223,37 @@ criteria = Criteria(
182
 
183
  estimate = estimate_pool(criteria)
184
  steps = sensitivity_table(criteria)
 
185
 
186
- col_a, col_b, col_c = st.columns(3)
187
  col_a.metric("Conservative estimate", format_count(estimate.conservative))
188
  col_b.metric("Central estimate", format_count(estimate.central))
189
  col_c.metric("Optimistic estimate", format_count(estimate.optimistic))
 
190
 
191
  st.subheader("What narrows the pool")
192
  step_df = pd.DataFrame(steps)
193
  display_df = step_df.assign(
194
  coefficient=step_df["coefficient"].map("{:.4f}".format),
195
  remaining=step_df["remaining"].map(format_count),
 
196
  )
197
  fig = px.bar(
198
  step_df,
199
  x="factor",
200
  y="remaining",
201
  text="remaining",
202
- custom_data=["coefficient"],
203
  title="Remaining estimated pool after each criterion",
204
  )
205
  fig.update_traces(texttemplate="%{text:,.0f}", textposition="outside")
206
  fig.update_traces(
207
- hovertemplate="<b>%{x}</b><br>Remaining: %{y:,.0f}<br>Coefficient: %{customdata[0]:.4f}<extra></extra>"
 
 
 
 
 
208
  )
209
  fig.update_layout(yaxis_title="Estimated remaining pool", xaxis_title="")
210
  st.plotly_chart(fig, use_container_width=True)
@@ -212,6 +261,17 @@ st.plotly_chart(fig, use_container_width=True)
212
  st.subheader("Scenario details")
213
  st.dataframe(display_df, use_container_width=True, hide_index=True)
214
 
 
 
 
 
 
 
 
 
 
 
 
215
  st.subheader("Data quality notes")
216
  for note in DATA_QUALITY_NOTES:
217
  st.write(f"- **{note['label']}**: {note['note']}")
@@ -222,3 +282,7 @@ st.write(
222
  "A stricter filter can make a pool smaller, but it does not define a person's real-life chances. "
223
  "War, children, housing, and lifestyle filters are sensitive context variables; treat them as transparent assumptions."
224
  )
 
 
 
 
 
4
  import plotly.express as px
5
  import streamlit as st
6
 
7
+ from src.assumptions import (
8
+ BASELINE,
9
+ BASELINE_REFERENCE_OPTIONS,
10
+ DATA_QUALITY_NOTES,
11
+ INCOME_THRESHOLD_LABELS,
12
+ SALARY_ANCHORS_UAH,
13
+ SOURCE_LINKS,
14
+ )
15
  from src.model_pool import Criteria, estimate_pool, sensitivity_table
16
 
17
 
 
30
  return value.replace("_", " ").title()
31
 
32
 
33
+ def income_label(value: str) -> str:
34
+ return INCOME_THRESHOLD_LABELS[value]
35
+
36
+
37
+ def format_percent(value: int | float) -> str:
38
+ if value == 0:
39
+ return "0%"
40
+ if value < 0.001:
41
+ return "<0.001%"
42
+ if value < 0.01:
43
+ return f"{value:.4f}%"
44
+ if value < 1:
45
+ return f"{value:.3f}%"
46
+ return f"{value:.2f}%"
47
+
48
+
49
  st.set_page_config(
50
  page_title="Partner Pool Assumption Simulator",
51
  page_icon="S7",
 
63
  with st.sidebar:
64
  st.header("Scenario")
65
  with st.expander("Core demographics", expanded=True):
66
+ baseline_preset = st.selectbox(
67
+ "Baseline preset",
68
+ list(BASELINE_REFERENCE_OPTIONS),
69
+ format_func=lambda value: BASELINE_REFERENCE_OPTIONS[value]["label"],
70
+ help=(
71
+ "Baseline is the starting universe before filters. It is not automatically the whole country; "
72
+ "choose a national reference or a narrower custom pool depending on the scenario."
73
+ ),
74
+ )
75
+ preset = BASELINE_REFERENCE_OPTIONS[baseline_preset]
76
  base_population_text = st.text_input(
77
  "Baseline population",
78
+ value=format_count(preset["value"]),
79
+ help=(
80
+ f"{preset['note']} Formatted with commas. "
81
+ "Example: 10,000,000 means a synthetic reference pool, not Ukraine's total population."
82
+ ),
83
  )
84
  try:
85
  base_population = parse_count(base_population_text, BASELINE.total_reference_population)
 
125
  income_level = st.selectbox(
126
  "Income threshold",
127
  ["any", "above_median", "top_25", "top_10"],
128
+ format_func=income_label,
129
+ help=(
130
+ "Salary anchors: Work.ua current benchmark is about "
131
+ f"{format_count(SALARY_ANCHORS_UAH['workua_current_average'])} UAH/month; "
132
+ f"KSE cites Work.ua January 2026 median at {format_count(SALARY_ANCHORS_UAH['kse_workua_jan_2026_median'])} UAH/month. "
133
+ "Top 25 and Top 10 are scenario thresholds, not official percentiles."
134
+ ),
135
  )
136
  education_level = st.selectbox(
137
  "Education filter",
 
223
 
224
  estimate = estimate_pool(criteria)
225
  steps = sensitivity_table(criteria)
226
+ central_percent = (estimate.central / criteria.base_population) * 100
227
 
228
+ col_a, col_b, col_c, col_d = st.columns(4)
229
  col_a.metric("Conservative estimate", format_count(estimate.conservative))
230
  col_b.metric("Central estimate", format_count(estimate.central))
231
  col_c.metric("Optimistic estimate", format_count(estimate.optimistic))
232
+ col_d.metric("Central share", format_percent(central_percent))
233
 
234
  st.subheader("What narrows the pool")
235
  step_df = pd.DataFrame(steps)
236
  display_df = step_df.assign(
237
  coefficient=step_df["coefficient"].map("{:.4f}".format),
238
  remaining=step_df["remaining"].map(format_count),
239
+ percent_of_baseline=step_df["percent_of_baseline"].map(format_percent),
240
  )
241
  fig = px.bar(
242
  step_df,
243
  x="factor",
244
  y="remaining",
245
  text="remaining",
246
+ custom_data=["coefficient", "percent_of_baseline"],
247
  title="Remaining estimated pool after each criterion",
248
  )
249
  fig.update_traces(texttemplate="%{text:,.0f}", textposition="outside")
250
  fig.update_traces(
251
+ hovertemplate=(
252
+ "<b>%{x}</b><br>"
253
+ "Remaining: %{y:,.0f}<br>"
254
+ "Share of baseline: %{customdata[1]:.4f}%<br>"
255
+ "Coefficient: %{customdata[0]:.4f}<extra></extra>"
256
+ )
257
  )
258
  fig.update_layout(yaxis_title="Estimated remaining pool", xaxis_title="")
259
  st.plotly_chart(fig, use_container_width=True)
 
261
  st.subheader("Scenario details")
262
  st.dataframe(display_df, use_container_width=True, hide_index=True)
263
 
264
+ st.subheader("Baseline and salary anchors")
265
+ st.write(
266
+ "Baseline population is the starting reference pool before filters. The default 10,000,000 is a demo working pool, "
267
+ "not the population of Ukraine. For a national pre-invasion reference, use the SSSU January 2022 option "
268
+ f"({format_count(BASELINE_REFERENCE_OPTIONS['sssu_jan_2022_total']['value'])})."
269
+ )
270
+ st.write(
271
+ f"`Above median` currently means roughly {format_count(SALARY_ANCHORS_UAH['workua_current_average'])} UAH/month "
272
+ "as a public job-market benchmark. Higher salary bands are scenario cutoffs until a validated percentile source is added."
273
+ )
274
+
275
  st.subheader("Data quality notes")
276
  for note in DATA_QUALITY_NOTES:
277
  st.write(f"- **{note['label']}**: {note['note']}")
 
282
  "A stricter filter can make a pool smaller, but it does not define a person's real-life chances. "
283
  "War, children, housing, and lifestyle filters are sensitive context variables; treat them as transparent assumptions."
284
  )
285
+
286
+ st.subheader("Sources")
287
+ for source in SOURCE_LINKS:
288
+ st.markdown(f"- [{source['label']}]({source['url']}) — {source['note']}")
src/assumptions.py CHANGED
@@ -12,6 +12,65 @@ class BaselineAssumptions:
12
 
13
  BASELINE = BaselineAssumptions()
14
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
  AGE_BAND_FACTORS = {
16
  "18-24": 0.12,
17
  "25-34": 0.22,
 
12
 
13
  BASELINE = BaselineAssumptions()
14
 
15
+ BASELINE_REFERENCE_OPTIONS = {
16
+ "demo_reference_pool": {
17
+ "label": "Demo working pool",
18
+ "value": 10_000_000,
19
+ "note": "Synthetic starting universe for scenario testing; not the full Ukraine population.",
20
+ },
21
+ "sssu_jan_2022_total": {
22
+ "label": "SSSU Jan 2022 total population",
23
+ "value": 41_167_335,
24
+ "note": "Official pre-full-scale-invasion total population estimate cited by ACAPS.",
25
+ },
26
+ "custom": {
27
+ "label": "Custom baseline",
28
+ "value": 10_000_000,
29
+ "note": "Use when modeling a narrower adult, regional, platform, or pre-filtered pool.",
30
+ },
31
+ }
32
+
33
+ SALARY_ANCHORS_UAH = {
34
+ "official_pfu_2025_average": 20_654,
35
+ "kse_workua_jan_2026_median": 27_500,
36
+ "workua_current_average": 28_600,
37
+ }
38
+
39
+ INCOME_THRESHOLD_LABELS = {
40
+ "any": "Any income",
41
+ "above_median": "Above median (about 28,600 UAH/month)",
42
+ "top_25": "Top 25 scenario (about 45,000 UAH/month)",
43
+ "top_10": "Top 10 scenario (about 70,000 UAH/month)",
44
+ }
45
+
46
+ SOURCE_LINKS = [
47
+ {
48
+ "label": "Work.ua salary statistics",
49
+ "url": "https://www.work.ua/en/salary-all/",
50
+ "note": "Current salary benchmark from job postings; Work.ua states that the median is calculated from recent vacancies.",
51
+ },
52
+ {
53
+ "label": "KSE Ukraine Monthly Economic Update, February 2026",
54
+ "url": "https://institute.kse.ua/wp-content/uploads/2026/02/ukraine_monthly_economic_update_eng_february_2026.pdf",
55
+ "note": "Cites Work.ua January 2026 offered median salary of UAH 27,500.",
56
+ },
57
+ {
58
+ "label": "Pension Fund of Ukraine average wage indicator, 2025",
59
+ "url": "https://www.pfu.gov.ua/2170600-pokaznyk-serednoyi-zarobitnoyi-platy-za-2025-rik/",
60
+ "note": "Official average wage indicator used for pension calculations; annual 2025 value is UAH 20,653.55.",
61
+ },
62
+ {
63
+ "label": "ACAPS Ukraine population data sources report",
64
+ "url": "https://www.acaps.org/fileadmin/Data_Product/Main_media/20230818_ACAPS_Thematic_report_Ukraine_estimates_and_sources_of_population_data.pdf",
65
+ "note": "Explains baseline population datasets and cites SSSU January 2022 total population estimate.",
66
+ },
67
+ {
68
+ "label": "State Statistics Service of Ukraine 2022 overview",
69
+ "url": "https://www.ukrstat.gov.ua/operativ/infografika/2022/o_soc_ek_Ukr/01_2022_e.pdf",
70
+ "note": "Official population-statistics context and methodology notes.",
71
+ },
72
+ ]
73
+
74
  AGE_BAND_FACTORS = {
75
  "18-24": 0.12,
76
  "25-34": 0.22,
src/model_pool.py CHANGED
@@ -127,7 +127,12 @@ def estimate_pool(criteria: Criteria) -> PoolEstimate:
127
  def sensitivity_table(criteria: Criteria) -> list[dict[str, float | str]]:
128
  remaining = float(criteria.base_population)
129
  rows: list[dict[str, float | str]] = [
130
- {"factor": "Baseline", "coefficient": 1.0, "remaining": remaining}
 
 
 
 
 
131
  ]
132
  for label, coefficient in model_factors(criteria):
133
  remaining *= coefficient
@@ -136,6 +141,7 @@ def sensitivity_table(criteria: Criteria) -> list[dict[str, float | str]]:
136
  "factor": label,
137
  "coefficient": round(coefficient, 4),
138
  "remaining": round(remaining, 2),
 
139
  }
140
  )
141
  return rows
 
127
  def sensitivity_table(criteria: Criteria) -> list[dict[str, float | str]]:
128
  remaining = float(criteria.base_population)
129
  rows: list[dict[str, float | str]] = [
130
+ {
131
+ "factor": "Baseline",
132
+ "coefficient": 1.0,
133
+ "remaining": remaining,
134
+ "percent_of_baseline": 100.0,
135
+ }
136
  ]
137
  for label, coefficient in model_factors(criteria):
138
  remaining *= coefficient
 
141
  "factor": label,
142
  "coefficient": round(coefficient, 4),
143
  "remaining": round(remaining, 2),
144
+ "percent_of_baseline": round((remaining / criteria.base_population) * 100, 6),
145
  }
146
  )
147
  return rows