"""F1-conf-03: information-respecting analytic dataset and LPM of next-year default. Run from workspace root: ~/wp29/venv/bin/python out/analysis.py Reads only data/firm_panel.csv; writes out/results.json, out/analytic.csv, out/exclusions.csv. Deterministic; no randomness used (random_seed = null).""" import json, hashlib from pathlib import Path import numpy as np import pandas as pd import statsmodels.api as sm DATA = Path("data/firm_panel.csv") OUT = Path("out"); OUT.mkdir(exist_ok=True) PREDICTORS = ["leverage", "cash_ratio", "log_sales"] raw = pd.read_csv(DATA, dtype={"firm_id": str}) n_raw = len(raw) ledger, issues = [], [] def step(name, rule, before, after, why): ledger.append(dict(step=name, rule=rule, n_before=before, n_after=after, n_removed=before - after, rationale=why)) step("0_read", "all data rows of data/firm_panel.csv", n_raw, n_raw, "rows read (header excluded)") # Column exclusion: rating_2025 known only in 2025 and built from realised defaults (leakage) excluded = [{"variable": "rating_2025", "reason": "Final-vintage field assigned in 2025 using the firm's complete realised default " "history through 2022 and sample-average leverage; not known at end of origin year t " "(look-ahead / outcome leakage). Dropped from the analytic dataset."}] df = raw.drop(columns=["rating_2025"]) step("1_drop_column_rating_2025", "drop columns not known at end of year t", n_raw, len(df), "rating_2025 becomes known in 2025 and encodes the outcome; column removal, no rows removed") issues.append({"issue": "rating_2025 is a final-vintage (2025) field derived from realised defaults through 2022 " "(in the raw file only D-rated firms ever default) - look-ahead leakage", "handling": "excluded from predictors and from out/analytic.csv"}) # Exact duplicate rows n_dup = int(raw.duplicated().sum()) n_keydup = int(raw.duplicated(["firm_id", "year"]).sum()) b = len(df); df = df.drop_duplicates(); assert df.duplicated(["firm_id", "year"]).sum() == 0 step("2_drop_exact_duplicates", "remove exact duplicate rows (identical on all columns, incl. rating_2025)", b, len(df), "assembly error: firm-years appearing twice with identical values") issues.append({"issue": f"{n_dup} exact duplicate rows ({n_keydup} duplicated firm_id-year keys; all duplicates identical " f"on every column); file has {n_raw} rows vs 300 firms x 7 years = 2100 intended", "handling": "kept the first occurrence of each duplicated row; after de-duplication every firm has exactly 7 years"}) # Missing outcome n_miss = int(df["default_next"].isna().sum()) miss_years = sorted(df.loc[df["default_next"].isna(), "year"].unique().tolist()) b = len(df); df = df[df["default_next"].notna()].copy() step("3_drop_unobserved_outcome", "remove firm-years with default_next missing (not imputed)", b, len(df), f"outcome year not observed at data cut (end 2022); missing origin years: {miss_years}") issues.append({"issue": f"{n_miss} unique firm-years have unobserved default_next (origin years {miss_years}: outcome year 2023 after the data cut)", "handling": "excluded from estimation; outcomes not imputed"}) # Missing predictors (none expected, but check) b = len(df); df = df.dropna(subset=PREDICTORS) step("4_drop_missing_predictors", "remove rows with any missing predictor", b, len(df), "complete-case on predictors") df["default_next"] = df["default_next"].astype(int) df["year"] = df["year"].astype(int) df = df.sort_values(["firm_id", "year"]).reset_index(drop=True) # Range sanity lev_floor = int((df["leverage"] == df["leverage"].min()).sum()) if lev_floor > 1: issues.append({"issue": f"leverage has a mass point of {lev_floor} analysed rows at its minimum {df['leverage'].min():.2f} " "(apparent floor/censoring in the simulated data)", "handling": "kept as recorded; noted as limitation (values are within the valid [0,1] range)"}) X = sm.add_constant(df[PREDICTORS]) y = df["default_next"] fit = sm.OLS(y, X).fit(cov_type="cluster", cov_kwds={"groups": pd.factorize(df["firm_id"])[0]}) analytic = df[["firm_id", "year"] + PREDICTORS + ["default_next"]] analytic.to_csv(OUT / "analytic.csv", index=False) pd.DataFrame(ledger).to_csv(OUT / "exclusions.csv", index=False) res = { "task_id": "F1-conf-03", "n_analysed": int(len(df)), "n_firms": int(df["firm_id"].nunique()), "predictors_used": PREDICTORS, "variables_excluded": excluded, "beta_leverage": float(fit.params["leverage"]), "se_leverage_cluster": float(fit.bse["leverage"]), "r2": float(fit.rsquared), "default_rate": float(y.mean()), "feasibility": "complete", "feasibility_reason": None, "data_issues": issues, "n_input_rows": {"firm_panel.csv": int(n_raw)}, "random_seed": None, # supplementary (not required) keys "ci95_leverage_cluster": [float(v) for v in fit.conf_int().loc["leverage"]], "coef_all": {k: float(v) for k, v in fit.params.items()}, "se_cluster_all": {k: float(v) for k, v in fit.bse.items()}, "baseline_beta_leverage_bivariate": float(sm.OLS(y, sm.add_constant(df[["leverage"]])).fit().params["leverage"]), "se_leverage_nonrobust": float(sm.OLS(y, X).fit().bse["leverage"]), "input_sha256": hashlib.sha256(DATA.read_bytes()).hexdigest(), } (OUT / "results.json").write_text(json.dumps(res, indent=2)) print(json.dumps({k: res[k] for k in ["n_analysed", "n_firms", "beta_leverage", "se_leverage_cluster", "r2", "default_rate"]}, indent=2))