"""F1-conf-03: information-respecting LPM of next-year default on leverage. 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. No randomness is used (random_seed = null).""" import json, os import numpy as np import pandas as pd import statsmodels.api as sm DATA = "data/firm_panel.csv" OUT = "out" os.makedirs(OUT, exist_ok=True) 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", "rows read from data/firm_panel.csv (excluding header)", n_raw, n_raw, "starting point") # 1. Remove look-ahead column (column removal, no rows removed) PREDICTORS = ["leverage", "cash_ratio", "log_sales"] excluded = [{"variable": "rating_2025", "reason": "Known only in 2025 (final-vintage field) and constructed from the firm's realised " "default history through 2022 and sample-average leverage; not available at end of " "origin year t and mechanically contains the outcome (look-ahead leakage)."}] df = raw.drop(columns=["rating_2025"]) step("1_drop_rating_2025", "drop column rating_2025 (known 2025, uses realised outcomes)", len(df), len(df), "information-availability rule; column removal only") _dr = raw.drop_duplicates().groupby("rating_2025")["default_next"].mean() leak_rate_by_rating = {k: float(v) for k, v in _dr.items()} issues.append({"issue": "Column rating_2025 is a final-vintage (2025) field built from realised defaults through 2022 " f"(look-ahead; observed default rate by rating: {leak_rate_by_rating}).", "handling": "Excluded from the analytic dataset and listed in variables_excluded."}) # 2. Exact duplicate rows n_keydup = int(raw.duplicated(["firm_id", "year"]).sum()) n_exdup = int(raw.duplicated().sum()) before = len(df) df = df.loc[~raw.duplicated()].copy() # duplicates judged on all original columns step("2_drop_exact_duplicates", "drop rows identical on all columns to an earlier row (keep first)", before, len(df), "assembly error: one intended row per firm-year; duplicates would double-weight observations") assert not df.duplicated(["firm_id", "year"]).any(), "conflicting firm-year duplicates remain" issues.append({"issue": f"{n_exdup} exact duplicate rows ({n_keydup} duplicated firm_id-year keys; all duplicates identical " f"on every column, no conflicting values).", "handling": "Kept the first occurrence of each, giving one row per firm-year (300 firms x 7 years)."}) # 3. Missing outcome n_miss = int(df["default_next"].isna().sum()) miss_years = sorted(df.loc[df["default_next"].isna(), "year"].unique().tolist()) before = len(df) df = df.loc[df["default_next"].notna()].copy() step("3_drop_missing_outcome", "drop firm-years with default_next empty (outcome year not observed by end-2022 cut)", before, len(df), "outcome not observed; no imputation allowed") issues.append({"issue": f"{n_miss} firm-years (after de-duplication) have missing default_next, all in origin year(s) {miss_years} " "(outcome year 2023 beyond the data cut).", "handling": "Excluded from estimation (no imputation); recorded in the exclusion ledger."}) # 4. Missing predictors (none expected; ledger step kept for completeness) before = len(df) df = df.dropna(subset=PREDICTORS) step("4_drop_missing_predictors", "drop rows with missing leverage, cash_ratio or log_sales", before, len(df), "complete-case for regressors") df["default_next"] = df["default_next"].astype(int) df = df.sort_values(["firm_id", "year"]).reset_index(drop=True) df[["firm_id", "year"] + PREDICTORS + ["default_next"]].to_csv(f"{OUT}/analytic.csv", index=False) pd.DataFrame(ledger).to_csv(f"{OUT}/exclusions.csv", index=False) # Baseline default_rate = float(df["default_next"].mean()) # Model: OLS LPM, SE clustered by firm X = sm.add_constant(df[PREDICTORS]) y = df["default_next"] groups = pd.factorize(df["firm_id"])[0] fit = sm.OLS(y, X).fit(cov_type="cluster", cov_kwds={"groups": groups}) b = float(fit.params["leverage"]); se = float(fit.bse["leverage"]) 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": b, "se_leverage_cluster": se, "r2": float(fit.rsquared), "default_rate": default_rate, "feasibility": "complete", "feasibility_reason": None, "data_issues": issues, "n_input_rows": {"firm_panel.csv": int(n_raw)}, "random_seed": None, # supplementary (not required) "ci95_leverage_cluster": [float(fit.conf_int().loc["leverage", 0]), float(fit.conf_int().loc["leverage", 1])], "pvalue_leverage_cluster": float(fit.pvalues["leverage"]), "beta_cash_ratio": float(fit.params["cash_ratio"]), "se_cash_ratio_cluster": float(fit.bse["cash_ratio"]), "beta_log_sales": float(fit.params["log_sales"]), "se_log_sales_cluster": float(fit.bse["log_sales"]), "intercept": float(fit.params["const"]), "default_rate_by_rating_2025_diagnostic": leak_rate_by_rating, "n_clusters": int(len(np.unique(groups))), "cov_type": "cluster-robust by firm_id (statsmodels default small-sample correction)", } with open(f"{OUT}/results.json", "w") as f: json.dump(res, f, indent=2) print(f"n_analysed={res['n_analysed']} n_firms={res['n_firms']} beta_leverage={b:.6f} se={se:.6f} " f"r2={res['r2']:.6f} default_rate={default_rate:.6f}")