""" population.py ============= Synthetic individual-level voter population, grounded in REAL aggregate results. The Israeli Central Elections Committee publishes per-settlement results (`expc.csv`: 25th Knesset, Nov 2022 — eligible voters, turnout, and a vote count per party-letter, for each of ~1,216 settlements). Our Elector simulation, however, reasons about *individuals*: each person has voted or not yet, supports some party, and (if not yet voted) can be chased by activists. This module bridges the two via **population synthesis** (a.k.a. synthetic reconstruction): each settlement row is expanded into individual synthetic voters whose marginals match the published aggregates **exactly** — • exactly `מצביעים` of them have `true_voted = True` • the valid-ballot party split matches the per-party columns while every individual row is fully synthetic (random name) and carries **no real personal data**. The result aggregates back to the real CEC numbers, which is what makes the DP experiments credible. Output schema (drop-in compatible with app.py's `simulate_voter_list`): voter_id : int unique across the whole population name : str synthetic Hebrew name (no real PII) city : str real settlement name (שם ישוב) true_voted : bool did this person turn out? true_party : str|NA party of a VALID ballot; "פסול" if invalid; for non-voters (they cast no ballot) latent_support: str underlying party preference of EVERY person. For valid voters == true_party. For non-voters and invalid voters it is *modelled*, not observed — this is the GOTV chase target a party would predict. NOTE on `latent_support` for non-voters: real published data only reveals the party split among people who *voted*. Non-voter preference is unobserved, so it is an explicit modelling assumption here (default: drawn from the settlement's own valid-vote distribution). Treat it as a tunable knob and a stated limitation, not ground truth. Usage ----- from population import synthesize_population, DEFAULT_SETTLEMENTS df = synthesize_population() # default subset df = synthesize_population(settlements=["חיפה"]) # custom subset python population.py # sanity-check printout """ from __future__ import annotations import re from pathlib import Path import numpy as np import pandas as pd # ============================================================================= # FILE LOCATION # ============================================================================= _HERE = Path(__file__).resolve().parent # The CSV lives in the parent research folder during development; for Hugging # Face deployment, copy it next to this file. We check both locations. _CSV_CANDIDATES = [_HERE / "expc.csv", _HERE.parent / "expc.csv"] def _default_csv_path() -> Path: for c in _CSV_CANDIDATES: if c.exists(): return c raise FileNotFoundError( "expc.csv not found. Looked in:\n " + "\n ".join(str(c) for c in _CSV_CANDIDATES) + "\nPass csv_path=... explicitly." ) # ============================================================================= # METADATA / PARTY-LETTER MAPPING # ============================================================================= # Non-party columns in expc.csv. META_COLS = ["סמל ועדה", "שם ישוב", "סמל ישוב", "בזב", "מצביעים", "פסולים", "כשרים"] COL_NAME = "שם ישוב" # settlement name COL_ELIGIBLE = "בזב" # eligible voters (בעלי זכות בחירה) COL_VOTERS = "מצביעים" # turned out COL_VALID = "כשרים" # valid ballots # Label given to a voter who turned out but cast an INVALID ballot (פסול). # Kept distinct from any real party and from (= did not vote at all). INVALID_LABEL = "פסול" # Bucket label for minor parties collapsed via `keep_top_parties` (see below). OTHER_LABEL = "אחר" # Output columns, in order. Importing code can rely on this schema. SCHEMA = ["voter_id", "name", "city", "true_voted", "true_party", "latent_support"] # Best-effort ballot-letter → readable party name, 25th Knesset (Nov 2022). # Used for display only; the simulation works on whatever labels are present, # so unmapped letters simply keep their original code. Edit freely. PARTY_LETTER_TO_NAME = { "מחל": "הליכוד", "פה": "יש עתיד", "ט": "הציונות הדתית", "כן": "המחנה הממלכתי", "שס": "שס", "ג": "יהדות התורה", "ל": "ישראל ביתנו", "עם": "רעם", "ום": "חדש-תעל", "אמת": "העבודה", "מרצ": "מרצ", "ד": "בלד", } # Synthetic names — fully made up, NOT linked to any real person. _FIRST_NAMES = [ "יוסי", "מיכל", "דוד", "שרה", "אבי", "רחל", "נועה", "אמיר", "תמר", "גיל", "לי", "עומר", "דינה", "ניר", "מאיה", "יובל", "רון", "הלה", "שי", "אורית", "ידין", "ליאור", "כרמל", "בר", "מוחמד", "פאטמה", "אחמד", "ליהאם", "נור", "סלים", "ראמי", "הודא", ] _LAST_NAMES = [ "כהן", "לוי", "מזרחי", "פרץ", "ביטון", "אברהם", "דהן", "שמש", "פרידמן", "חסן", "ח'ורי", "עבאס", "ברק", "אזולאי", "גבאי", "סבן", ] # ============================================================================= # DEFAULT SUBSET (large cities + small disclosure-at-risk settlements) # ============================================================================= # Chosen to exercise both stories at once: # • large cities → DP noise is negligible at scale (utility preserved) # • small ones → many single-vote party cells → real reconstruction risk DEFAULT_LARGE_SETTLEMENTS = [ "תל אביב יפו", "ירושלים", "חיפה", "ראשון לציון", "פתח תקווה", "נתניה", ] DEFAULT_SMALL_SETTLEMENTS = [ "אלוני הבשן", "גיתה", "מנרה", "נעמה", "גזר", "בארות יצחק", ] DEFAULT_SETTLEMENTS = DEFAULT_LARGE_SETTLEMENTS + DEFAULT_SMALL_SETTLEMENTS # ============================================================================= # LOADING / SUBSETTING # ============================================================================= def _normalize(s: str) -> str: """Collapse runs of whitespace so 'תל אביב יפו' matches 'תל אביב יפו'.""" return re.sub(r"\s+", " ", str(s)).strip() def load_settlements_table(csv_path=None) -> pd.DataFrame: """Load the raw CEC per-settlement table (one row per settlement).""" path = Path(csv_path) if csv_path else _default_csv_path() df = pd.read_csv(path) df[COL_NAME] = df[COL_NAME].map(_normalize) return df def party_columns(df: pd.DataFrame) -> list[str]: """The party-letter columns = everything that isn't metadata.""" return [c for c in df.columns if c not in META_COLS] def subset_settlements(df: pd.DataFrame, settlements) -> pd.DataFrame: """Return rows for the requested settlement names (whitespace-insensitive).""" wanted = {_normalize(s) for s in settlements} out = df[df[COL_NAME].isin(wanted)].copy() found = set(out[COL_NAME]) missing = wanted - found if missing: raise ValueError( "Settlement(s) not found in expc.csv: " + ", ".join(sorted(missing)) + ".\nCheck spelling against load_settlements_table()['שם ישוב']." ) return out # ============================================================================= # SUPPORT MODELS (how the UNOBSERVED preference of non-/invalid-voters is drawn) # ============================================================================= # A support model is a callable: # model(rng, parties, observed_probs, size) -> np.ndarray[str] # where `parties` are the (display) party labels and `observed_probs` is the # settlement's valid-vote distribution over them (or None if no valid votes). # Real data never reveals non-voter preference, so this is the modelling knob # distinguishing, e.g., a perfectly-informed party from a noisy predictor. # Pass a string name (resolved via SUPPORT_MODELS) or your own callable. def _support_observed(rng, parties, observed_probs, size): """Non-voters split exactly like the settlement's actual voters.""" if observed_probs is None: return rng.choice(parties, size=size) return rng.choice(parties, size=size, p=observed_probs) def _support_uniform(rng, parties, observed_probs, size): """Non-voters split uniformly across parties (preference is uninformative).""" return rng.choice(parties, size=size) def make_noisy_support_model(noise: float): """ Factory: blend the observed distribution with the uniform one. effective = (1 - noise) * observed + noise * uniform noise=0 → identical to "observed"; noise=1 → identical to "uniform". Handy for Design A, where a party's prediction of non-voter support is imperfect: raise `noise` to model a worse predictor. """ if not (0.0 <= noise <= 1.0): raise ValueError("noise must be in [0, 1].") def model(rng, parties, observed_probs, size): k = len(parties) unif = np.full(k, 1.0 / k) base = unif if observed_probs is None else np.asarray(observed_probs) probs = (1.0 - noise) * base + noise * unif probs = probs / probs.sum() return rng.choice(parties, size=size, p=probs) return model # Registry of built-in models, resolvable by name. SUPPORT_MODELS = { "observed": _support_observed, "uniform": _support_uniform, } def resolve_support_model(support_model): """Accept a callable as-is, or look a string up in SUPPORT_MODELS.""" if callable(support_model): return support_model try: return SUPPORT_MODELS[support_model] except KeyError: raise ValueError( f"Unknown support_model {support_model!r}. " f"Use a callable or one of: {sorted(SUPPORT_MODELS)}." ) # ============================================================================= # POPULATION SYNTHESIS # ============================================================================= def _make_names(rng: np.random.Generator, n: int) -> np.ndarray: """Vectorised synthetic 'first last' names.""" first = rng.choice(_FIRST_NAMES, size=n) last = rng.choice(_LAST_NAMES, size=n) return np.char.add(np.char.add(first.astype(str), " "), last.astype(str)) def _settlement_scale(eligible_raw, fraction, max_per_settlement): """ Single scale factor folding together proportional down-sampling and an optional absolute per-settlement cap. scale == 1.0 ⇒ exact real marginals. """ scale = fraction if max_per_settlement is not None and eligible_raw * scale > max_per_settlement: scale = max_per_settlement / eligible_raw return scale def _synthesize_one(row, parties, rng, start_id, scale, rename, support): """ Expand a single settlement row into an individual-level DataFrame whose marginals match the published aggregates (scaled by `scale`). Parameters ---------- row : the settlement row from the CEC table. parties : list of party COLUMN labels (letters) in the table. rng : numpy Generator. start_id: first voter_id to assign. scale : proportional size factor (1.0 = exact real counts). rename : {letter: display_name} map (output labels only). support : resolved support-model callable for unobserved preferences. """ eligible_raw = int(row[COL_ELIGIBLE]) if eligible_raw <= 0: return None eligible = max(1, int(round(eligible_raw * scale))) voted_n = min(eligible, int(round(int(row[COL_VOTERS]) * scale))) # Per-party VALID vote counts (scaled consistently with voted_n). raw_counts = np.array([max(0, int(row[c])) for c in parties], dtype=float) party_counts = np.round(raw_counts * scale).astype(int) valid_total = int(party_counts.sum()) display_parties = [rename.get(p, p) for p in parties] probs = (party_counts / valid_total) if valid_total > 0 else None # ---- voters: assign a ballot label to exactly `voted_n` individuals ----- # Build the valid-ballot labels by repeating each party by its count, then # pad/truncate to voted_n; the remainder are invalid ballots (INVALID_LABEL). valid_labels = np.repeat(display_parties, party_counts) if valid_labels.size > voted_n: valid_labels = rng.permutation(valid_labels)[:voted_n] n_invalid = voted_n - valid_labels.size voted_party = np.concatenate([ valid_labels, np.full(n_invalid, INVALID_LABEL, dtype=object), ]) rng.shuffle(voted_party) n_nonvoters = eligible - voted_n # ---- latent support (underlying preference) for everyone --------------- # Valid voters: their actual vote. Invalid voters + non-voters: drawn from # the chosen support model (the modelled GOTV chase target). voted_support = np.where( voted_party == INVALID_LABEL, support(rng, display_parties, probs, voted_n), voted_party, ) nonvoter_support = support(rng, display_parties, probs, n_nonvoters) # ---- assemble ---------------------------------------------------------- true_voted = np.concatenate([ np.ones(voted_n, dtype=bool), np.zeros(n_nonvoters, dtype=bool), ]) true_party = np.concatenate([ voted_party, np.full(n_nonvoters, pd.NA, dtype=object), ]) latent = np.concatenate([voted_support, nonvoter_support]) n = eligible out = pd.DataFrame({ "voter_id": np.arange(start_id, start_id + n), "name": _make_names(rng, n), "city": _normalize(row[COL_NAME]), "true_voted": true_voted, "true_party": pd.array(true_party, dtype="string"), "latent_support": pd.array(latent.astype(object), dtype="string"), }) return out def synthesize_population( settlements=None, csv_path=None, seed: int = 42, fraction: float = 1.0, max_per_settlement=None, support_model="observed", keep_top_parties=None, rename_parties: bool = True, ) -> pd.DataFrame: """ Build a synthetic individual-level population for the chosen settlements. Parameters ---------- settlements : list[str] | None Settlement names to include. None → DEFAULT_SETTLEMENTS. csv_path : str | Path | None Path to expc.csv. None → auto-locate. seed : int RNG seed for reproducibility. fraction : float Proportional down-sample factor in (0, 1]. 1.0 = full real size. Preserves each settlement's distribution shape; use e.g. 0.01 to keep an interactive app responsive. max_per_settlement : int | None Optional absolute cap on synthetic voters per settlement (applied on top of `fraction`). Lets large cities and tiny settlements coexist in one responsive population. None = no cap. support_model : str | callable How to draw the UNOBSERVED preference of non-/invalid-voters. A name in SUPPORT_MODELS ("observed", "uniform") or a callable (rng, parties, observed_probs, size) -> array. See make_noisy_support_model. keep_top_parties : int | None If set, keep only the N largest parties (by total vote across the subset) and collapse the rest into OTHER_LABEL ("אחר"). Keeps the k of k-randomized-response small (better utility, fewer chart bars) for the app. None = full granularity, e.g. for the reconstruction experiment. rename_parties : bool Map ballot letters to readable party names (display only). Returns ------- pd.DataFrame One row per synthetic voter (schema == SCHEMA), shuffled so settlements are interleaved. With fraction == 1.0 and no cap, marginals match expc.csv exactly. """ if not (0.0 < fraction <= 1.0): raise ValueError("fraction must be in (0, 1].") if max_per_settlement is not None and max_per_settlement < 1: raise ValueError("max_per_settlement must be >= 1 or None.") settlements = list(settlements) if settlements is not None else DEFAULT_SETTLEMENTS support = resolve_support_model(support_model) table = load_settlements_table(csv_path) sub = subset_settlements(table, settlements) parties = party_columns(table) # Build the letter → display-label map. Kept parties get a readable name # (or their letter); collapsed minor parties all map to OTHER_LABEL. rename = dict(PARTY_LETTER_TO_NAME) if rename_parties else {} if keep_top_parties is not None: totals = sub[parties].sum().sort_values(ascending=False) kept = set(totals.index[:int(keep_top_parties)]) for p in parties: if p not in kept: rename[p] = OTHER_LABEL rng = np.random.default_rng(seed) frames, next_id = [], 0 for _, row in sub.iterrows(): scale = _settlement_scale(int(row[COL_ELIGIBLE]), fraction, max_per_settlement) part = _synthesize_one(row, parties, rng, next_id, scale, rename, support) if part is not None and len(part): frames.append(part) next_id += len(part) if not frames: raise ValueError("No voters synthesised (empty/zero-eligible subset).") pop = pd.concat(frames, ignore_index=True) # Interleave settlements and reassign contiguous ids. pop = pop.sample(frac=1.0, random_state=seed).reset_index(drop=True) pop["voter_id"] = np.arange(len(pop)) return pop # ============================================================================= # DERIVED VIEWS (convenience accessors for downstream code, e.g. the app) # ============================================================================= def settlement_names(pop: pd.DataFrame) -> list[str]: """Settlements present in `pop`, ordered by synthetic population (desc).""" return pop.groupby("city").size().sort_values(ascending=False).index.tolist() def party_candidates(pop: pd.DataFrame) -> list[str]: """ The real parties present (the candidate set for k-randomized-response), ordered by modelled support (desc). Excludes INVALID_LABEL and ; based on `latent_support`, which is defined for every individual. """ counts = pop["latent_support"].dropna().value_counts() cand = [c for c in counts.index.tolist() if c != INVALID_LABEL] # Keep the collapsed "Other" bucket last so it never becomes the organiser. if OTHER_LABEL in cand: cand = [c for c in cand if c != OTHER_LABEL] + [OTHER_LABEL] return cand # ============================================================================= # VERIFICATION / SANITY CHECK # ============================================================================= def verify_against_source(pop: pd.DataFrame, csv_path=None, settlements=None): """ Compare synthesised marginals to the real CEC table, per settlement. Returns a tidy DataFrame; intended for the __main__ printout / tests. Only meaningful when fraction == 1.0 (exact match expected). """ table = load_settlements_table(csv_path) names = settlements or sorted(pop["city"].unique()) sub = subset_settlements(table, names) rows = [] for _, r in sub.iterrows(): city = _normalize(r[COL_NAME]) p = pop[pop.city == city] rows.append({ "city": city, "eligible_real": int(r[COL_ELIGIBLE]), "eligible_syn": len(p), "voted_real": int(r[COL_VOTERS]), "voted_syn": int(p.true_voted.sum()), "valid_real": int(r[COL_VALID]), "valid_syn": int(((p.true_voted) & (p.true_party != INVALID_LABEL) & (p.true_party.notna())).sum()), }) return pd.DataFrame(rows) def _main(): import sys # UTF-8 stdout for Hebrew on Windows consoles. try: sys.stdout.reconfigure(encoding="utf-8") except Exception: pass print("Synthesising default subset (full real size)...") pop = synthesize_population() print(f"\nTotal synthetic voters: {len(pop):,}") print(f"Settlements: {pop.city.nunique()}") print(f"Overall turnout: {pop.true_voted.mean():.1%}") print("\nSample rows:") print(pop.sample(8, random_state=1).to_string(index=False)) print("\nMarginals vs. real CEC data (should match exactly):") chk = verify_against_source(pop) print(chk.to_string(index=False)) ok = (chk.eligible_real == chk.eligible_syn).all() and \ (chk.voted_real == chk.voted_syn).all() print("\n✅ marginals match" if ok else "\n❌ MISMATCH — investigate") print("\nTop latent-support parties (incl. non-voters' modelled preference):") print(pop.latent_support.value_counts().head(8).to_string()) if __name__ == "__main__": _main()