Publish partner pool simulator space
Browse files- Dockerfile +12 -0
- README.md +12 -4
- app.py +100 -0
- data/sources.yml +59 -0
- requirements.txt +4 -0
- src/__init__.py +1 -0
- src/assumptions.py +76 -0
- src/model_pool.py +111 -0
Dockerfile
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
COPY requirements.txt .
|
| 6 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 7 |
+
|
| 8 |
+
COPY . .
|
| 9 |
+
|
| 10 |
+
EXPOSE 7860
|
| 11 |
+
|
| 12 |
+
CMD ["streamlit", "run", "app.py", "--server.port=7860", "--server.address=0.0.0.0", "--server.headless=true"]
|
README.md
CHANGED
|
@@ -1,10 +1,18 @@
|
|
| 1 |
---
|
| 2 |
-
title: Partner
|
| 3 |
-
emoji:
|
| 4 |
colorFrom: blue
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Partner Pool Simulator 05-2026
|
| 3 |
+
emoji: 📊
|
| 4 |
colorFrom: blue
|
| 5 |
+
colorTo: green
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
| 10 |
+
# Partner Pool Assumption Simulator
|
| 11 |
+
|
| 12 |
+
Transparent Streamlit prototype for `S7-K · Personal Relationship`.
|
| 13 |
+
|
| 14 |
+
This Space is a public demo for estimating how relationship criteria narrow an assumed demographic pool. Current coefficients are demo assumptions and must not be interpreted as factual population counts.
|
| 15 |
+
|
| 16 |
+
Source study:
|
| 17 |
+
|
| 18 |
+
- [K-RnD-Lab / SPHERE-I-SCIENCE](https://github.com/K-RnD-Lab/SPHERE-I-SCIENCE/tree/main/S7%20%E2%80%94%20%F0%9F%93%9A%20K%20Life%20OS/S7-K%20%C2%B7%20%F0%9F%91%A5%20Personal%20Relationship/R1a-partner-pool-assumption-simulator)
|
app.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
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 |
+
|
| 11 |
+
st.set_page_config(
|
| 12 |
+
page_title="Partner Pool Assumption Simulator",
|
| 13 |
+
page_icon="S7",
|
| 14 |
+
layout="wide",
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
st.title("Partner Pool Assumption Simulator")
|
| 18 |
+
st.caption("S7-K · Personal Relationship · transparent demo model")
|
| 19 |
+
|
| 20 |
+
st.info(
|
| 21 |
+
"Prototype status: current numbers are demo assumptions. Use this app to test model logic, "
|
| 22 |
+
"not to claim a factual count of available partners."
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
with st.sidebar:
|
| 26 |
+
st.header("Scenario")
|
| 27 |
+
base_population = st.number_input(
|
| 28 |
+
"Baseline population",
|
| 29 |
+
min_value=10_000,
|
| 30 |
+
max_value=50_000_000,
|
| 31 |
+
value=BASELINE.total_reference_population,
|
| 32 |
+
step=50_000,
|
| 33 |
+
)
|
| 34 |
+
age_min, age_max = st.slider("Age range", 18, 70, (28, 42))
|
| 35 |
+
region_scope = st.selectbox(
|
| 36 |
+
"Region scope",
|
| 37 |
+
["all_ukraine", "large_cities", "kyiv_region", "western_regions"],
|
| 38 |
+
format_func=lambda value: value.replace("_", " ").title(),
|
| 39 |
+
)
|
| 40 |
+
relationship_status = st.selectbox(
|
| 41 |
+
"Relationship status",
|
| 42 |
+
["any", "not_married", "single_or_divorced"],
|
| 43 |
+
format_func=lambda value: value.replace("_", " ").title(),
|
| 44 |
+
)
|
| 45 |
+
min_height = st.slider("Minimum height, cm", 150, 205, 175)
|
| 46 |
+
income_level = st.selectbox(
|
| 47 |
+
"Income threshold",
|
| 48 |
+
["any", "above_median", "top_25", "top_10"],
|
| 49 |
+
format_func=lambda value: value.replace("_", " ").title(),
|
| 50 |
+
)
|
| 51 |
+
education_level = st.selectbox(
|
| 52 |
+
"Education filter",
|
| 53 |
+
["any", "higher_education", "graduate_plus"],
|
| 54 |
+
format_func=lambda value: value.replace("_", " ").title(),
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
criteria = Criteria(
|
| 58 |
+
base_population=base_population,
|
| 59 |
+
age_min=age_min,
|
| 60 |
+
age_max=age_max,
|
| 61 |
+
region_scope=region_scope,
|
| 62 |
+
relationship_status=relationship_status,
|
| 63 |
+
min_height_cm=min_height,
|
| 64 |
+
income_level=income_level,
|
| 65 |
+
education_level=education_level,
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
estimate = estimate_pool(criteria)
|
| 69 |
+
steps = sensitivity_table(criteria)
|
| 70 |
+
|
| 71 |
+
col_a, col_b, col_c = st.columns(3)
|
| 72 |
+
col_a.metric("Conservative estimate", f"{estimate.conservative:,.0f}")
|
| 73 |
+
col_b.metric("Central estimate", f"{estimate.central:,.0f}")
|
| 74 |
+
col_c.metric("Optimistic estimate", f"{estimate.optimistic:,.0f}")
|
| 75 |
+
|
| 76 |
+
st.subheader("What narrows the pool")
|
| 77 |
+
step_df = pd.DataFrame(steps)
|
| 78 |
+
fig = px.bar(
|
| 79 |
+
step_df,
|
| 80 |
+
x="factor",
|
| 81 |
+
y="remaining",
|
| 82 |
+
text="remaining",
|
| 83 |
+
title="Remaining estimated pool after each criterion",
|
| 84 |
+
)
|
| 85 |
+
fig.update_traces(texttemplate="%{text:,.0f}", textposition="outside")
|
| 86 |
+
fig.update_layout(yaxis_title="Estimated remaining pool", xaxis_title="")
|
| 87 |
+
st.plotly_chart(fig, use_container_width=True)
|
| 88 |
+
|
| 89 |
+
st.subheader("Scenario details")
|
| 90 |
+
st.dataframe(step_df, use_container_width=True, hide_index=True)
|
| 91 |
+
|
| 92 |
+
st.subheader("Data quality notes")
|
| 93 |
+
for note in DATA_QUALITY_NOTES:
|
| 94 |
+
st.write(f"- **{note['label']}**: {note['note']}")
|
| 95 |
+
|
| 96 |
+
st.subheader("Interpretation guardrails")
|
| 97 |
+
st.write(
|
| 98 |
+
"This model estimates a demographic scenario, not compatibility, attraction, safety, or relationship success. "
|
| 99 |
+
"A stricter filter can make a pool smaller, but it does not define a person's real-life chances."
|
| 100 |
+
)
|
data/sources.yml
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
sources:
|
| 2 |
+
- id: ukraine_nowpop
|
| 3 |
+
name: Ukraine NowPop
|
| 4 |
+
url: https://nowpop.org/
|
| 5 |
+
use_for:
|
| 6 |
+
- population_by_age_sex_region
|
| 7 |
+
status: candidate
|
| 8 |
+
confidence: medium
|
| 9 |
+
notes: Current population estimates should be reviewed for license, geographic granularity, and update cadence.
|
| 10 |
+
|
| 11 |
+
- id: ukrstat
|
| 12 |
+
name: State Statistics Service of Ukraine
|
| 13 |
+
url: https://stat.gov.ua/
|
| 14 |
+
use_for:
|
| 15 |
+
- baseline_demography
|
| 16 |
+
- wages
|
| 17 |
+
- social_indicators
|
| 18 |
+
status: candidate
|
| 19 |
+
confidence: medium
|
| 20 |
+
notes: Official source, but some demographic series may lag wartime mobility.
|
| 21 |
+
|
| 22 |
+
- id: ministry_of_justice_marriage
|
| 23 |
+
name: Ministry of Justice marriage and divorce administrative data
|
| 24 |
+
url: https://minjust.gov.ua/
|
| 25 |
+
use_for:
|
| 26 |
+
- marriages
|
| 27 |
+
- divorces
|
| 28 |
+
status: candidate
|
| 29 |
+
confidence: medium
|
| 30 |
+
notes: Useful for flow indicators, not a direct measure of single or available population.
|
| 31 |
+
|
| 32 |
+
- id: opendatabot_marriage_divorce
|
| 33 |
+
name: Opendatabot marriage/divorce analytics
|
| 34 |
+
url: https://opendatabot.ua/
|
| 35 |
+
use_for:
|
| 36 |
+
- public_context
|
| 37 |
+
- administrative_data_summary
|
| 38 |
+
status: candidate
|
| 39 |
+
confidence: low_to_medium
|
| 40 |
+
notes: Aggregated public analytics; validate against primary Ministry of Justice releases before using in model coefficients.
|
| 41 |
+
|
| 42 |
+
- id: ncd_risc_height
|
| 43 |
+
name: NCD Risk Factor Collaboration anthropometric data
|
| 44 |
+
url: https://ncdrisc.org/
|
| 45 |
+
use_for:
|
| 46 |
+
- height_distribution_proxy
|
| 47 |
+
status: candidate_proxy
|
| 48 |
+
confidence: low
|
| 49 |
+
notes: Use only if Ukraine-specific height distribution is unavailable; label as proxy.
|
| 50 |
+
|
| 51 |
+
- id: world_bank_living_conditions
|
| 52 |
+
name: World Bank Ukraine living conditions updates
|
| 53 |
+
url: https://www.worldbank.org/en/country/ukraine
|
| 54 |
+
use_for:
|
| 55 |
+
- income_context
|
| 56 |
+
- living_conditions_context
|
| 57 |
+
status: candidate
|
| 58 |
+
confidence: medium
|
| 59 |
+
notes: Useful for context and uncertainty framing rather than direct demographic filtering.
|
requirements.txt
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
streamlit>=1.35
|
| 2 |
+
pandas>=2.2
|
| 3 |
+
plotly>=5.22
|
| 4 |
+
PyYAML>=6.0
|
src/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
|
src/assumptions.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
@dataclass(frozen=True)
|
| 7 |
+
class BaselineAssumptions:
|
| 8 |
+
total_reference_population: int = 10_000_000
|
| 9 |
+
uncertainty_low: float = 0.72
|
| 10 |
+
uncertainty_high: float = 1.28
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
BASELINE = BaselineAssumptions()
|
| 14 |
+
|
| 15 |
+
AGE_BAND_FACTORS = {
|
| 16 |
+
"18-24": 0.12,
|
| 17 |
+
"25-34": 0.22,
|
| 18 |
+
"35-44": 0.20,
|
| 19 |
+
"45-54": 0.18,
|
| 20 |
+
"55-70": 0.28,
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
REGION_FACTORS = {
|
| 24 |
+
"all_ukraine": 1.0,
|
| 25 |
+
"large_cities": 0.34,
|
| 26 |
+
"kyiv_region": 0.13,
|
| 27 |
+
"western_regions": 0.24,
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
RELATIONSHIP_STATUS_FACTORS = {
|
| 31 |
+
"any": 1.0,
|
| 32 |
+
"not_married": 0.46,
|
| 33 |
+
"single_or_divorced": 0.32,
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
HEIGHT_FACTORS = {
|
| 37 |
+
160: 0.92,
|
| 38 |
+
165: 0.82,
|
| 39 |
+
170: 0.67,
|
| 40 |
+
175: 0.48,
|
| 41 |
+
180: 0.28,
|
| 42 |
+
185: 0.13,
|
| 43 |
+
190: 0.04,
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
INCOME_FACTORS = {
|
| 47 |
+
"any": 1.0,
|
| 48 |
+
"above_median": 0.42,
|
| 49 |
+
"top_25": 0.25,
|
| 50 |
+
"top_10": 0.10,
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
EDUCATION_FACTORS = {
|
| 54 |
+
"any": 1.0,
|
| 55 |
+
"higher_education": 0.38,
|
| 56 |
+
"graduate_plus": 0.16,
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
DATA_QUALITY_NOTES = [
|
| 60 |
+
{
|
| 61 |
+
"label": "Population",
|
| 62 |
+
"note": "Replace demo baseline with current age-sex population estimates before publication.",
|
| 63 |
+
},
|
| 64 |
+
{
|
| 65 |
+
"label": "Relationship status",
|
| 66 |
+
"note": "Official marital status does not equal real availability; label as estimated.",
|
| 67 |
+
},
|
| 68 |
+
{
|
| 69 |
+
"label": "Income",
|
| 70 |
+
"note": "Income and salary filters are sensitive to self-employment and informal earnings.",
|
| 71 |
+
},
|
| 72 |
+
{
|
| 73 |
+
"label": "Height",
|
| 74 |
+
"note": "Height currently requires proxy distribution unless a Ukraine-specific source is validated.",
|
| 75 |
+
},
|
| 76 |
+
]
|
src/model_pool.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
|
| 5 |
+
from .assumptions import (
|
| 6 |
+
AGE_BAND_FACTORS,
|
| 7 |
+
BASELINE,
|
| 8 |
+
EDUCATION_FACTORS,
|
| 9 |
+
HEIGHT_FACTORS,
|
| 10 |
+
INCOME_FACTORS,
|
| 11 |
+
REGION_FACTORS,
|
| 12 |
+
RELATIONSHIP_STATUS_FACTORS,
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@dataclass(frozen=True)
|
| 17 |
+
class Criteria:
|
| 18 |
+
base_population: int
|
| 19 |
+
age_min: int
|
| 20 |
+
age_max: int
|
| 21 |
+
region_scope: str
|
| 22 |
+
relationship_status: str
|
| 23 |
+
min_height_cm: int
|
| 24 |
+
income_level: str
|
| 25 |
+
education_level: str
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
@dataclass(frozen=True)
|
| 29 |
+
class PoolEstimate:
|
| 30 |
+
conservative: float
|
| 31 |
+
central: float
|
| 32 |
+
optimistic: float
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def age_factor(age_min: int, age_max: int) -> float:
|
| 36 |
+
bands = {
|
| 37 |
+
"18-24": (18, 24),
|
| 38 |
+
"25-34": (25, 34),
|
| 39 |
+
"35-44": (35, 44),
|
| 40 |
+
"45-54": (45, 54),
|
| 41 |
+
"55-70": (55, 70),
|
| 42 |
+
}
|
| 43 |
+
selected = 0.0
|
| 44 |
+
for label, (band_min, band_max) in bands.items():
|
| 45 |
+
overlap_min = max(age_min, band_min)
|
| 46 |
+
overlap_max = min(age_max, band_max)
|
| 47 |
+
if overlap_min <= overlap_max:
|
| 48 |
+
band_width = band_max - band_min + 1
|
| 49 |
+
overlap_width = overlap_max - overlap_min + 1
|
| 50 |
+
selected += AGE_BAND_FACTORS[label] * (overlap_width / band_width)
|
| 51 |
+
return max(0.01, min(selected, 1.0))
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def height_factor(min_height_cm: int) -> float:
|
| 55 |
+
thresholds = sorted(HEIGHT_FACTORS)
|
| 56 |
+
if min_height_cm <= thresholds[0]:
|
| 57 |
+
return HEIGHT_FACTORS[thresholds[0]]
|
| 58 |
+
if min_height_cm >= thresholds[-1]:
|
| 59 |
+
return HEIGHT_FACTORS[thresholds[-1]]
|
| 60 |
+
|
| 61 |
+
lower = max(threshold for threshold in thresholds if threshold <= min_height_cm)
|
| 62 |
+
upper = min(threshold for threshold in thresholds if threshold >= min_height_cm)
|
| 63 |
+
if lower == upper:
|
| 64 |
+
return HEIGHT_FACTORS[lower]
|
| 65 |
+
|
| 66 |
+
ratio = (min_height_cm - lower) / (upper - lower)
|
| 67 |
+
return HEIGHT_FACTORS[lower] + ratio * (HEIGHT_FACTORS[upper] - HEIGHT_FACTORS[lower])
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def model_factors(criteria: Criteria) -> list[tuple[str, float]]:
|
| 71 |
+
return [
|
| 72 |
+
("Age range", age_factor(criteria.age_min, criteria.age_max)),
|
| 73 |
+
("Region scope", REGION_FACTORS[criteria.region_scope]),
|
| 74 |
+
("Relationship status", RELATIONSHIP_STATUS_FACTORS[criteria.relationship_status]),
|
| 75 |
+
("Minimum height", height_factor(criteria.min_height_cm)),
|
| 76 |
+
("Income threshold", INCOME_FACTORS[criteria.income_level]),
|
| 77 |
+
("Education filter", EDUCATION_FACTORS[criteria.education_level]),
|
| 78 |
+
]
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def central_estimate(criteria: Criteria) -> float:
|
| 82 |
+
value = float(criteria.base_population)
|
| 83 |
+
for _, factor in model_factors(criteria):
|
| 84 |
+
value *= factor
|
| 85 |
+
return value
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def estimate_pool(criteria: Criteria) -> PoolEstimate:
|
| 89 |
+
central = central_estimate(criteria)
|
| 90 |
+
return PoolEstimate(
|
| 91 |
+
conservative=central * BASELINE.uncertainty_low,
|
| 92 |
+
central=central,
|
| 93 |
+
optimistic=central * BASELINE.uncertainty_high,
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def sensitivity_table(criteria: Criteria) -> list[dict[str, float | str]]:
|
| 98 |
+
remaining = float(criteria.base_population)
|
| 99 |
+
rows: list[dict[str, float | str]] = [
|
| 100 |
+
{"factor": "Baseline", "coefficient": 1.0, "remaining": remaining}
|
| 101 |
+
]
|
| 102 |
+
for label, coefficient in model_factors(criteria):
|
| 103 |
+
remaining *= coefficient
|
| 104 |
+
rows.append(
|
| 105 |
+
{
|
| 106 |
+
"factor": label,
|
| 107 |
+
"coefficient": round(coefficient, 4),
|
| 108 |
+
"remaining": round(remaining, 2),
|
| 109 |
+
}
|
| 110 |
+
)
|
| 111 |
+
return rows
|