TEZv's picture
Make default scenario neutral
e5317eb
from __future__ import annotations
import pandas as pd
import plotly.express as px
import streamlit as st
from src.assumptions import (
BASELINE,
BASELINE_REFERENCE_OPTIONS,
DATA_QUALITY_NOTES,
INCOME_THRESHOLD_OPTIONS_UAH,
SALARY_ANCHORS_UAH,
SOURCE_LINKS,
)
from src.model_pool import Criteria, estimate_pool, sensitivity_table
def format_count(value: int | float) -> str:
return f"{value:,.0f}"
def parse_count(value: str, fallback: int) -> int:
cleaned = value.replace(",", "").replace(" ", "").strip()
if not cleaned:
return fallback
return int(cleaned)
def title_label(value: str) -> str:
return value.replace("_", " ").title()
def income_threshold_label(value: int) -> str:
if value == 0:
return "Any income"
return f"{format_count(value)} UAH"
def format_percent(value: int | float) -> str:
if value == 0:
return "0%"
if value < 0.001:
return "<0.001%"
if value < 0.01:
return f"{value:.4f}%"
if value < 1:
return f"{value:.3f}%"
return f"{value:.2f}%"
st.set_page_config(
page_title="Partner Pool Assumption Simulator",
page_icon="S7",
layout="wide",
)
st.title("Partner Pool Assumption Simulator")
st.caption("S7-K · Personal Relationship · transparent demo model")
st.info(
"Prototype status: current numbers are demo assumptions. Use this app to test model logic, "
"not to claim a factual count of available partners."
)
with st.sidebar:
st.header("Scenario")
with st.expander("Core demographics", expanded=True):
baseline_preset = st.selectbox(
"Baseline preset",
list(BASELINE_REFERENCE_OPTIONS),
format_func=lambda value: BASELINE_REFERENCE_OPTIONS[value]["label"],
help=(
"Baseline is the starting universe before filters. It is not automatically the whole country; "
"choose a national reference or a narrower custom pool depending on the scenario."
),
)
preset = BASELINE_REFERENCE_OPTIONS[baseline_preset]
base_population_text = st.text_input(
"Baseline population",
value=format_count(preset["value"]),
help=(
f"{preset['note']} Formatted with commas. "
"Demo is a fixed synthetic example. Custom means you choose your own starting audience."
),
)
try:
base_population = parse_count(base_population_text, BASELINE.total_reference_population)
except ValueError:
st.warning("Use digits with optional commas, for example 10,000,000.")
base_population = BASELINE.total_reference_population
if not 10_000 <= base_population <= 50_000_000:
st.warning("Baseline population should stay between 10,000 and 50,000,000 for this demo.")
base_population = max(10_000, min(base_population, 50_000_000))
target_population = st.selectbox(
"Target population",
["all_adults", "women", "men"],
format_func=title_label,
help="Applies a demo sex-share coefficient before the other filters. Women: 53%, Men: 47%, All adults: 100%.",
)
age_min, age_max = st.slider(
"Age range",
18,
70,
(18, 70),
help="Narrows the pool by the selected age-band overlap. The full 18-70 range is treated as no age filter.",
)
region_scope = st.selectbox(
"Region scope",
["all_ukraine", "large_cities", "kyiv_region", "western_regions"],
format_func=title_label,
help="Applies the selected regional scope coefficient.",
)
relationship_status = st.selectbox(
"Relationship status",
["any", "not_married", "single_or_divorced"],
format_func=title_label,
help="Demo availability proxy. Official marital status is not the same as real availability.",
)
min_height = st.slider(
"Minimum height, cm",
150,
205,
150,
help="Interpolates a demo height-distribution coefficient. 150 cm is treated as no height filter.",
)
income_min_uah = st.select_slider(
"Minimum monthly income, UAH",
options=INCOME_THRESHOLD_OPTIONS_UAH,
value=0,
format_func=income_threshold_label,
help=(
"Scenario salary threshold. 0 means no income filter. Salary anchors: Work.ua current benchmark is about "
f"{format_count(SALARY_ANCHORS_UAH['workua_current_average'])} UAH/month; "
f"KSE cites Work.ua January 2026 median at {format_count(SALARY_ANCHORS_UAH['kse_workua_jan_2026_median'])} UAH/month. "
"High thresholds up to 1,000,000 UAH/month are scenario stress-test cutoffs, not official maximum salary data."
),
)
st.caption(
"Selected income threshold: "
f"{'Any income' if income_min_uah == 0 else format_count(income_min_uah) + ' UAH/month'}"
)
education_level = st.selectbox(
"Education filter",
["any", "higher_education", "graduate_plus"],
format_func=title_label,
help="Applies an estimated education-level coefficient.",
)
with st.expander("Family context"):
children_status = st.selectbox(
"Children status",
["any", "no_children", "has_children", "co_parenting_ready"],
format_func=title_label,
help="Scenario preference around existing children. These are demo assumptions, not value judgments.",
)
future_children = st.selectbox(
"Future children",
["any", "wants_children", "does_not_want_children", "open_or_undecided"],
format_func=title_label,
help="Scenario preference around future children.",
)
with st.expander("War and mobility"):
military_status = st.selectbox(
"Military status",
["any", "civilian_or_not_serving", "active_service", "veteran_or_service_history"],
format_func=title_label,
help="War-related scenario filter. Active service and veteran/service-history shares are placeholders until sourced.",
)
relocation = st.selectbox(
"Relocation",
["any", "same_city_only", "open_to_relocation", "remote_or_long_distance_ok"],
format_func=title_label,
help="Mobility and distance preference filter.",
)
with st.expander("Lifestyle and compatibility"):
housing = st.selectbox(
"Housing",
["any", "independent_living", "own_or_stable_housing"],
format_func=title_label,
help="Scenario proxy for independent or stable living setup.",
)
smoking = st.selectbox(
"Smoking",
["any", "non_smoker", "ok_with_smoking"],
format_func=title_label,
help="Lifestyle preference around smoking.",
)
alcohol = st.selectbox(
"Alcohol",
["any", "rare_or_none", "moderate_ok"],
format_func=title_label,
help="Lifestyle preference around alcohol use.",
)
language = st.selectbox(
"Language comfort",
["any", "ukrainian_comfortable", "english_comfortable", "ukrainian_and_english"],
format_func=title_label,
help="Communication comfort filter.",
)
pets = st.selectbox(
"Pets",
["any", "pet_friendly", "no_pets_preferred"],
format_func=title_label,
help="Household compatibility preference around pets.",
)
criteria = Criteria(
base_population=base_population,
target_population=target_population,
age_min=age_min,
age_max=age_max,
region_scope=region_scope,
relationship_status=relationship_status,
min_height_cm=min_height,
income_min_uah=income_min_uah,
education_level=education_level,
children_status=children_status,
future_children=future_children,
military_status=military_status,
relocation=relocation,
housing=housing,
smoking=smoking,
alcohol=alcohol,
language=language,
pets=pets,
)
estimate = estimate_pool(criteria)
steps = sensitivity_table(criteria)
central_percent = (estimate.central / criteria.base_population) * 100
col_a, col_b, col_c, col_d = st.columns(4)
col_a.metric("Conservative estimate", format_count(estimate.conservative))
col_b.metric("Central estimate", format_count(estimate.central))
col_c.metric("Optimistic estimate", format_count(estimate.optimistic))
col_d.metric("Central share", format_percent(central_percent))
if central_percent == 100:
st.caption("Neutral defaults are active: the central estimate equals 100% of the selected baseline.")
else:
st.caption("Central share is the central estimate divided by the selected baseline after all active filters.")
st.subheader("What narrows the pool")
step_df = pd.DataFrame(steps)
display_df = step_df.assign(
coefficient=step_df["coefficient"].map("{:.4f}".format),
remaining=step_df["remaining"].map(format_count),
percent_of_baseline=step_df["percent_of_baseline"].map(format_percent),
)
fig = px.bar(
step_df,
x="factor",
y="remaining",
text="remaining",
custom_data=["coefficient", "percent_of_baseline"],
title="Remaining estimated pool after each criterion",
)
fig.update_traces(texttemplate="%{text:,.0f}", textposition="outside")
fig.update_traces(
hovertemplate=(
"<b>%{x}</b><br>"
"Remaining: %{y:,.0f}<br>"
"Share of baseline: %{customdata[1]:.4f}%<br>"
"Coefficient: %{customdata[0]:.4f}<extra></extra>"
)
)
fig.update_layout(yaxis_title="Estimated remaining pool", xaxis_title="")
st.plotly_chart(fig, use_container_width=True)
st.subheader("Scenario details")
st.dataframe(display_df, use_container_width=True, hide_index=True)
st.subheader("Baseline and salary anchors")
st.write(
"Baseline population is the starting reference pool before filters. The default 10,000,000 is a demo working pool, "
"not the population of Ukraine. For a national pre-invasion reference, use the SSSU January 2022 option "
f"({format_count(BASELINE_REFERENCE_OPTIONS['sssu_jan_2022_total']['value'])})."
)
st.write(
f"The income slider uses {format_count(SALARY_ANCHORS_UAH['workua_current_average'])} UAH/month as the current public job-market benchmark. "
"High values such as 200,000, 500,000, or 1,000,000 UAH/month are supported as scenario stress-test cutoffs. "
"They are not official salary percentiles or a claimed real maximum."
)
st.subheader("Data quality notes")
for note in DATA_QUALITY_NOTES:
st.write(f"- **{note['label']}**: {note['note']}")
st.subheader("Interpretation guardrails")
st.write(
"This model estimates a demographic scenario, not compatibility, attraction, safety, or relationship success. "
"A stricter filter can make a pool smaller, but it does not define a person's real-life chances. "
"War, children, housing, and lifestyle filters are sensitive context variables; treat them as transparent assumptions."
)
st.subheader("Sources")
for source in SOURCE_LINKS:
st.markdown(f"- [{source['label']}]({source['url']}) — {source['note']}")