"""F1-conf-02: balanced-panel mean life expectancy 2000-2022. Run from workspace root: ~/wp29/venv/bin/python out/analysis.py Reads only data/; writes out/results.json, out/analytic.csv, out/exclusions.csv. No randomness is used (random_seed = null).""" import json, os import pandas as pd DATA = "data/life_expectancy_panel.csv" OUT = "out" YEARS = list(range(2000, 2023)) os.makedirs(OUT, exist_ok=True) raw = pd.read_csv(DATA, dtype={"iso3": str, "country_name": str}) n_raw = len(raw) for c in ["iso3", "country_name"]: raw[c] = raw[c].str.strip() excl = [] issues = [] def step(name, rule, before, after, why): excl.append(dict(step=name, rule=rule, n_before=len(before), n_after=len(after), n_removed=len(before) - len(after), rationale=why)) # Step 1: restrict to study window d0 = raw d1 = d0[d0["year"].between(2000, 2022)].copy() step("1_year_window", "keep 2000 <= year <= 2022", d0, d1, "study window defined by the task") # Step 2: duplicated (iso3, year) rows dup_mask = d1.duplicated(["iso3", "year"], keep=False) dups = d1[dup_mask] conflicting = dups.groupby(["iso3", "year"])["life_expectancy"].nunique(dropna=False) conflicting = conflicting[conflicting > 1] if len(conflicting): raise SystemExit(f"Conflicting duplicate values found: {conflicting}") for iso, g in dups.groupby("iso3"): names = sorted(g["country_name"].unique()) issues.append({ "issue": f"{iso}: {g[['iso3','year']].drop_duplicates().shape[0]} country-years appear " f"{len(g)} times in total under country names {names} (two extractions; API renamed the " f"country); duplicate values are identical", "handling": "country identified by iso3; kept one row per (iso3, year) (first occurrence); " "values identical so choice does not affect results"}) d2 = d1.drop_duplicates(["iso3", "year"], keep="first").copy() step("2_dedup_iso3_year", "keep one row per (iso3, year); duplicates have identical values", d1, d2, "each country contributes at most one observation per year (assembly duplicates)") # harmonise display name to the most recent spelling (last occurrence in file) per iso3 latest_name = d1.groupby("iso3")["country_name"].last() d2["country_name"] = d2["iso3"].map(latest_name) # Completeness per country miss_rows = d2[d2["life_expectancy"].isna()] for _, r in miss_rows.iterrows(): issues.append({"issue": f"{r.iso3} {int(r.year)}: empty life_expectancy cell", "handling": "treated as missing (no imputation); country excluded from balanced set"}) obs = d2.dropna(subset=["life_expectancy"]) years_by = obs.groupby("iso3")["year"].apply(set) all_iso = sorted(d2["iso3"].unique()) balanced = sorted(i for i in all_iso if set(YEARS) <= years_by.get(i, set())) incomplete = [i for i in all_iso if i not in balanced] for i in incomplete: present = years_by.get(i, set()) missing_years = sorted(set(YEARS) - present) if len(missing_years) > 1 and missing_years == list(range(missing_years[0], missing_years[-1] + 1)): desc = f"{missing_years[0]}-{missing_years[-1]}" else: desc = ",".join(map(str, missing_years)) if not (len(missing_years) == 1 and i in set(miss_rows.iso3)): issues.append({"issue": f"{i}: no rows/values for years {desc} ({len(missing_years)} of 23 years missing)", "handling": "treated as missing (no imputation); country excluded from balanced set"}) # Step 3: drop rows with empty value d3 = d2.dropna(subset=["life_expectancy"]) step("3_drop_missing_value", "drop rows with empty life_expectancy", d2, d3, "a country-year without a recorded value is missing; no imputation") # Step 4: drop countries not observed in all 23 years d4 = d3[d3["iso3"].isin(balanced)] step("4_balanced_panel", "keep only iso3 with a recorded value in every year 2000-2022", d3, d4, f"balanced panel; excluded countries: {', '.join(incomplete)}") analytic = d4[["iso3", "country_name", "year", "life_expectancy"]].sort_values(["iso3", "year"]).reset_index(drop=True) assert len(analytic) == len(balanced) * len(YEARS) assert not analytic.duplicated(["iso3", "year"]).any() analytic.to_csv(f"{OUT}/analytic.csv", index=False) pd.DataFrame(excl).to_csv(f"{OUT}/exclusions.csv", index=False) m = analytic.groupby("year")["life_expectancy"].mean() m2000, m2022 = float(m[2000]), float(m[2022]) avail22 = d3[d3["year"] == 2022] # already one row per iso3 results = { "task_id": "F1-conf-02", "n_countries_total": len(all_iso), "n_countries_balanced": len(balanced), "n_countries_incomplete": len(incomplete), "balanced_countries": balanced, "n_analysed": int(len(analytic)), "mean_2000_balanced": round(m2000, 6), "mean_2022_balanced": round(m2022, 6), "change_2000_2022_balanced": round(m2022 - m2000, 6), "mean_2022_all_available": round(float(avail22["life_expectancy"].mean()), 6), "n_countries_2022_available": int(avail22["iso3"].nunique()), "incomplete_countries": incomplete, "balanced_means_by_year": {str(y): round(float(v), 6) for y, v in m.items()}, "feasibility": "complete", "feasibility_reason": None, "data_issues": issues, "n_input_rows": {"life_expectancy_panel.csv": int(n_raw)}, "random_seed": None, } with open(f"{OUT}/results.json", "w") as f: json.dump(results, f, indent=2) print(json.dumps({k: results[k] for k in ["n_countries_total", "n_countries_balanced", "n_analysed", "mean_2000_balanced", "mean_2022_balanced", "change_2000_2022_balanced", "mean_2022_all_available"]}, indent=1))