"""
=============================================================================
CS329T · 共享模块：一个"知道真相"的合成信贷审批世界（synthetic credit-approval world）
=============================================================================

对应讲义：lectures/lecture01_trustworthy_ml_intro.md（第 2~10 讲的经典部分也会导入这个模块）

-----------------------------------------------------------------------------
【为什么是信贷审批？ Why credit approval?】
-----------------------------------------------------------------------------
CS329T 2022 年第一节课的第一个例子就是信用评分（credit classifier）：输入收入、信用历史长度、
逾期次数、负债收入比……输出"批 / 不批"。这个场景把可信机器学习的几个问题都放在了一起：
    · Why?            被拒的人有权知道原因（美国 ECOA / Regulation B 要求贷款机构给出拒绝的主要理由）。
    · For whom?       不同人群的批准率、"本该批却被拒"的比例是否一样？（fairness）
    · What if?        输入里一点点测量误差、或者申请人"刷"一下某个特征，决策会不会翻转？（robustness）
    · What leaks?     训练好的模型会不会泄露"某人是否在训练集里"？（privacy）
    · How sure?       模型说"还款概率 0.9"，真的有 90% 的人还款吗？（calibration）

-----------------------------------------------------------------------------
【为什么用合成数据？ Why synthetic data?】
-----------------------------------------------------------------------------
真实数据里我们不知道"真相"：不知道真实的还款概率 P(repay | x)，不知道哪个特征是数据泄露，
也不知道两个人群的差异来自哪里。合成数据由我们自己按已知的公式生成，所以可以：
    · 精确计算每个人的真实还款概率 p_true（"上帝视角" oracle），检查模型是否 calibrated；
    · 故意埋下一个**数据泄露（label leakage）**特征，看解释方法能不能把它找出来；
    · 故意让两个人群的特征分布不同、并且让一个特征对两个人群的意义不同，
      看"模型不使用群体标签"（fairness through unawareness）能不能避免不公平。
代价是：数字只说明机制（mechanism），不代表任何真实的信贷系统或真实人群。

-----------------------------------------------------------------------------
【这个世界的设定 The data-generating process】
-----------------------------------------------------------------------------
每个申请人有一个群体标签 group ∈ {0, 1}（0 = 多数群体 70%，1 = 少数群体 30%）。
group **不会**作为特征交给模型，但它影响特征的分布：

    特征 feature        单位        group 0                 group 1                 说明
    income              k$/年       lognormal，中位数 60     lognormal，中位数 48     结构性收入差距
    debt_ratio          比例        N(0.35, 0.12) 截断       同左                     负债收入比
    history             年          N(9, 4) 截断             N(4.5, 3) 截断           少数群体"信用档案薄"（thin file）
    missed              次          Poisson(0.8)            同左                     近两年逾期次数
    inquiries           次          Poisson(1.5)            同左                     近期征信查询次数
    officer_flag        0/1         见下                    见下                     ⚠ 数据泄露

真实的还款概率（y = 1 表示"会按时还款"，是"好结果"）：

    logit P(repay) = 0.9 + 0.9·inc_z − 0.8·debt_z + β_hist(group)·hist_z − 0.7·(missed − 0.8) − 0.25·(inquiries − 1.5)

    inc_z = (log income − log 55)/0.4,   debt_z = (debt_ratio − 0.35)/0.12,   hist_z = (history − 8)/4
    β_hist(0) = 0.8，β_hist(1) = 0.15

两个故意埋下的"坑"：

    ① thin-file 效应：对 group 1 来说，信用历史短**几乎不代表风险**（比如刚移民、刚工作的人还没有信用卡），
       所以 β_hist(1) 远小于 β_hist(0)。一个不看 group 的线性模型只能学到一个"平均的"历史系数，
       而 group 1 的历史普遍更短 —— 模型会系统性地低估 group 1 的还款概率。
       这是"群体差异"的第二个来源；第一个来源是收入和历史的分布本身不同（base rate 不同）。

    ② officer_flag（信贷员标记）：在**历史数据库**里，这个字段经常是在贷款出问题**之后**才被补填的
       （"这笔贷款被转交催收"）。所以在历史数据里它和标签高度相关：
           P(flag = 1 | 违约) = 0.80，  P(flag = 1 | 还款) = 0.05
       但在**真正做审批决策的时刻**，贷款还没发放，这个字段永远是空的（= 0）。
       这就是 label leakage：训练 / 测试时看起来是"超强特征"，部署时毫无用处。
       它对应 CS329T 2023 第 1 讲的例子：X 光癌症分类器的 attribution 落在了放射科医生的笔迹上（pen marks）。

【约定 Conventions】
    · X 是 n × 6 的矩阵，列的顺序见 FEATURES；y、group、p_true 是长度 n 的数组。
    · 每个生成器都接收一个 rng = np.random.default_rng(seed)：同一个 seed → 同样的数据。
      每个演示用自己的 rng，修改一个演示不会改变其他演示的数字。

【numpy 小知识】
    rng.normal(mean, std, size)    正态分布；mean 可以是数组（每个样本有自己的均值）
    rng.lognormal(mu, sigma, size) 对数正态：log(结果) ~ N(mu, sigma²)
    rng.poisson(lam, size)         泊松分布（计数）
    rng.random(size)               U(0, 1) 均匀分布
    np.where(cond, a, b)           逐元素的"如果 cond 就取 a，否则取 b"
    np.clip(a, lo, hi)             把 a 截断到 [lo, hi]
"""

import numpy as np

# 特征名（列的顺序）。LEGIT_FEATURES 是"决策时真的拿得到"的特征（不含泄露的 officer_flag）。
FEATURES = ["income", "debt_ratio", "history", "missed", "inquiries", "officer_flag"]
LEGIT = [0, 1, 2, 3, 4]          # 前 5 列的下标
FLAG = 5                         # officer_flag 所在的列
GROUP_NAMES = {0: "group 0 (majority)", 1: "group 1 (minority)"}

# 真实世界的参数 —— 只有"上帝视角"知道，模型看不到。
TRUE_INTERCEPT = 0.9
TRUE_BETA = {"income": 0.9, "debt_ratio": -0.8, "missed": -0.7, "inquiries": -0.25}
TRUE_BETA_HISTORY = {0: 0.8, 1: 0.15}      # thin-file 效应：对 group 1，历史长度几乎不预测还款
FLAG_RATE = {0: 0.80, 1: 0.05}             # P(officer_flag = 1 | y)：y=0 违约 → 0.80；y=1 还款 → 0.05


def true_logit(X, group):
    """
    计算"上帝视角"的真实 logit，即 log[P(repay) / (1 − P(repay))]。

    【思路】按模块开头的公式逐项相加。注意 officer_flag **不出现**在真实公式里：
        它只是"结果发生之后"被记录下来的，和还款没有因果关系。
    【参数】X：n × 6 特征矩阵（列顺序见 FEATURES）；group：长度 n 的 0/1 数组
    【返回】长度 n 的 logit 数组
    【边界】income 必须 > 0（取对数）；生成器保证了这一点。
    """
    inc_z = (np.log(X[:, 0]) - np.log(55.0)) / 0.4
    debt_z = (X[:, 1] - 0.35) / 0.12
    hist_z = (X[:, 2] - 8.0) / 4.0
    beta_hist = np.where(group == 1, TRUE_BETA_HISTORY[1], TRUE_BETA_HISTORY[0])
    return (TRUE_INTERCEPT
            + TRUE_BETA["income"] * inc_z
            + TRUE_BETA["debt_ratio"] * debt_z
            + beta_hist * hist_z
            + TRUE_BETA["missed"] * (X[:, 3] - 0.8)
            + TRUE_BETA["inquiries"] * (X[:, 4] - 1.5))


def true_repay_prob(X, group):
    """真实还款概率 p_true = sigmoid(true_logit)。这是一个完美 calibrated 的"oracle 模型"。"""
    return 1.0 / (1.0 + np.exp(-true_logit(X, group)))


def make_credit(n, rng, minority_frac=0.3, deployment=False):
    """
    生成 n 个申请人。

    【思路】
        1. 先抽群体标签 group（少数群体比例 minority_frac）；
        2. 按群体抽 5 个"正当"特征（收入、负债率、信用历史、逾期、查询）；
        3. 用 true_repay_prob 算出每个人的真实还款概率，再抽一次硬币得到标签 y；
        4. 最后生成泄露特征 officer_flag：
             historical（deployment=False）：flag 依赖于 y —— 这是"事后补填"的记录；
             deployment（deployment=True）：决策时贷款还没发放，flag 永远是 0。
        注意第 4 步发生在第 3 步**之后**：flag 是 y 的结果，不是原因。这正是 label leakage 的本质。

    【参数】
        n             申请人数（≥ 1）
        rng           np.random.Generator，例如 np.random.default_rng(0)
        minority_frac group 1 的比例，默认 0.3
        deployment    False = 历史数据（训练 / 测试用）；True = 部署时刻的数据（flag 全为 0）

    【返回】一个 dict：
        "X"      n × 6 的 float 矩阵（列顺序见 FEATURES）
        "y"      长度 n 的 0/1 int 数组（1 = 还款）
        "group"  长度 n 的 0/1 int 数组
        "p_true" 长度 n 的真实还款概率

    【边界】
        · minority_frac 取 0 或 1 时只有一个群体；后面按群体统计的函数会遇到空群体，需要自己处理。
        · 同一个 rng 连续调用两次会得到**不同**的数据（rng 的内部状态往前走了）。
          想要"同一份数据"，就用同一个 seed 新建 rng。
        · deployment=True 时 y 仍然按真实概率生成 —— 我们假设"事后"能观察到结果，用来评估部署效果。

    【Python 小知识】函数返回 dict（字典）而不是一串值，调用方写 d["X"]、d["y"] 取用，
        不容易把顺序搞混。
    """
    group = (rng.random(n) < minority_frac).astype(int)

    # 收入：对数正态。group 0 中位数 60k，group 1 中位数 48k（结构性差距）。
    income = rng.lognormal(mean=np.where(group == 1, np.log(48.0), np.log(60.0)), sigma=0.4)
    # 负债收入比：两个群体相同。截断到 [0.02, 0.95]，避免出现负数或大于 1。
    debt_ratio = np.clip(rng.normal(0.35, 0.12, n), 0.02, 0.95)
    # 信用历史（年）：group 1 平均更短 —— thin file。截断到 [0.5, 30]。
    history = np.clip(rng.normal(np.where(group == 1, 4.5, 9.0), np.where(group == 1, 3.0, 4.0)), 0.5, 30.0)
    # 逾期次数、征信查询次数：两个群体相同的泊松分布。
    missed = rng.poisson(0.8, n).astype(float)
    inquiries = rng.poisson(1.5, n).astype(float)

    X = np.column_stack([income, debt_ratio, history, missed, inquiries, np.zeros(n)])
    p_true = true_repay_prob(X, group)
    y = (rng.random(n) < p_true).astype(int)

    if not deployment:
        # 历史数据库：flag 在结果发生之后补填，所以它依赖 y。
        flag_prob = np.where(y == 1, FLAG_RATE[1], FLAG_RATE[0])
        X[:, FLAG] = (rng.random(n) < flag_prob).astype(float)
    # deployment=True：flag 保持全 0（决策时字段为空）。

    return {"X": X, "y": y, "group": group, "p_true": p_true}


def describe(d, name="data"):
    """
    打印一份数据的基本统计：每个群体的人数、还款率（base rate）、各特征的均值。

    【参数】d：make_credit 返回的 dict；name：打印用的名字
    【返回】None（只打印）
    【边界】某个群体人数为 0 时跳过该群体。
    """
    print(f"  {name}: n = {len(d['y'])}")
    header = "    " + f"{'':<22}{'n':>6}{'repay rate':>12}" + "".join(f"{f:>13}" for f in FEATURES)
    print(header)
    for g in (0, 1):
        m = d["group"] == g
        if m.sum() == 0:
            continue
        means = d["X"][m].mean(axis=0)
        print("    " + f"{GROUP_NAMES[g]:<22}{int(m.sum()):>6}{d['y'][m].mean():>12.3f}"
              + "".join(f"{v:>13.3f}" for v in means))


if __name__ == "__main__":
    # 直接运行本文件：做一个小自检，看看两个群体的差异和泄露特征的样子。
    rng = np.random.default_rng(0)
    hist = make_credit(20000, rng)
    dep = make_credit(20000, np.random.default_rng(1), deployment=True)
    print("自检 self-check：历史数据 vs 部署数据")
    describe(hist, "historical")
    describe(dep, "deployment")
    y, f = hist["y"], hist["X"][:, FLAG]
    print(f"\n  历史数据里 P(flag=1 | 违约) = {f[y == 0].mean():.3f}，P(flag=1 | 还款) = {f[y == 1].mean():.3f}")
    print(f"  真实概率 p_true 的平均 = {hist['p_true'].mean():.3f}，实际还款率 = {y.mean():.3f}（应该很接近）")
