"""
=============================================================================
第 5 讲 · 工具模块：公平指标"动物园"、不可能定理的检验量、代理变量、个体公平、置信区间
=============================================================================

对应讲义：lectures/lecture05_measuring_fairness.md

-----------------------------------------------------------------------------
【这个模块里有什么 What's inside】
-----------------------------------------------------------------------------
第 1 讲的 group_metrics 只给出每个群体的 approve / TPR / FPR / PPV / ECE。第 5 讲要把它们组织成一个体系，
并补上"怎么判断一个差距是真的"、"怎么判断群体信息有没有藏在别的特征里"、"个体层面的公平怎么量"：

    讲义    函数                                 作用
    §3      confusion_rates, fairness_metrics    一个群体的混淆矩阵比例；两个群体之间的全部常用指标（按三个准则分组）
            METRICS                              每个指标属于哪个准则、需不需要标签、怎么读
    §3、§9  bootstrap_metrics, wilson_ci         置信区间：bootstrap（任何指标）和 Wilson（单个比例，小样本也可靠）
    §4      max_dp_gap, max_sep_gap,             分数层面的三个准则的"违反程度"：所有阈值下最大的批准率差距、
            calibration_gap, balance_stats       最大的 TPR/FPR 差距、校准曲线之间的差距；Kleinberg 的 balance 量
    §6      predict_group, balanced_error_rate   用其他特征预测群体标签有多准（Feldman et al. 2015 的 BER）
            same_risk_gap                        "同样风险的人，批准率差多少"
    §5      knn_indices, consistency,            个体公平：k 近邻的决策一致性（Zemel et al. 2013）、
            twin_gap, within_group_percentiles   跨群体"最近的双胞胎"之间的分数差、按群体内百分位定义的相似度

【约定 Conventions】
    · y = 1 是好结果（还款 / 批准）；yhat = 1 是"批准"；group ∈ {0, 1}，group 1 是"不利群体"（unprivileged）。
    · 差值类指标一律写成 **group 1 − group 0**（和 IBM AIF360 工具包的约定相同：负数 = group 1 处于不利地位）；
      比值类指标写成 group 1 / group 0。
    · 所有函数只依赖 numpy，以及第 1 讲的 trust_tools（LogisticModel、auc、ece）。

【注释标签】【思路】【参数】【返回】【边界】【可信联系】【numpy 小知识】【Python 小知识】
"""

import sys
from pathlib import Path

import numpy as np

sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "lecture01"))

from trust_tools import LogisticModel, auc, ece  # noqa: E402


# =============================================================================
# 1. 一个群体的混淆矩阵比例
# =============================================================================
def _safe_div(a, b):
    """a / b；分母为 0 时返回 nan（而不是报错或警告）。"""
    return float(a) / float(b) if b > 0 else float("nan")


def confusion_rates(y, yhat):
    """
    一个群体内部的混淆矩阵（confusion matrix）计数和各种比例。

    【思路】二分类的一切"率"都来自四个计数：
            TP = 批准且还款，FP = 批准但违约，FN = 拒绝但其实会还款，TN = 拒绝且违约。
        不同的公平指标只是**用不同的分母**去除这些计数：
            sel = (TP + FP) / n          批准率（selection rate）—— 分母是所有人
            TPR = TP / (TP + FN)         会还款的人里被批准的比例 —— 分母是"真正合格的人"
            FPR = FP / (FP + TN)         会违约的人里被批准的比例 —— 分母是"真正不合格的人"
            PPV = TP / (TP + FP)         被批准的人里真的还款的比例 —— 分母是"被批准的人"
            NPV = TN / (TN + FN)         被拒绝的人里真的违约的比例 —— 分母是"被拒绝的人"
        "以谁为分母"就是"站在谁的角度看"：这是理解三大公平准则的钥匙（§3）。
    【参数】y：0/1 真实标签；yhat：0/1 决策（同长度）
    【返回】dict：n, base, sel, tpr, fpr, fnr, ppv, npv, acc, tp, fp, fn, tn
    【边界】某个分母为 0（比如没人被批准）时对应的率是 nan。
    """
    y = np.asarray(y)
    yhat = np.asarray(yhat)
    tp = int(np.sum((yhat == 1) & (y == 1)))
    fp = int(np.sum((yhat == 1) & (y == 0)))
    fn = int(np.sum((yhat == 0) & (y == 1)))
    tn = int(np.sum((yhat == 0) & (y == 0)))
    n = tp + fp + fn + tn
    return {
        "n": n, "tp": tp, "fp": fp, "fn": fn, "tn": tn,
        "base": _safe_div(tp + fn, n),
        "sel": _safe_div(tp + fp, n),
        "tpr": _safe_div(tp, tp + fn),
        "fpr": _safe_div(fp, fp + tn),
        "fnr": _safe_div(fn, tp + fn),
        "ppv": _safe_div(tp, tp + fp),
        "npv": _safe_div(tn, tn + fn),
        "acc": _safe_div(tp + tn, n),
    }


# =============================================================================
# 2. 两个群体之间的全部常用指标
# =============================================================================
# 每个汇总指标的"身份证"：(名字, 所属准则, 是否需要真实标签 y, 理想值, 一句话解释)
# 【Python 小知识】这是一个"元组的列表"；脚本里用 for name, crit, needs_y, ideal, desc in METRICS 逐个取出。
METRICS = [
    ("SPD", "independence", False, 0.0, "statistical parity difference = sel_1 − sel_0"),
    ("DIR", "independence", False, 1.0, "disparate impact ratio = sel_1 / sel_0（四分之五规则看它 ≥ 0.8）"),
    ("EOD", "separation", True, 0.0, "equal opportunity difference = TPR_1 − TPR_0"),
    ("FPR_diff", "separation", True, 0.0, "FPR_1 − FPR_0"),
    ("AOD", "separation", True, 0.0, "average odds difference = [(FPR_1 − FPR_0) + (TPR_1 − TPR_0)] / 2"),
    ("EqOdds_max", "separation", True, 0.0, "max(|TPR_1 − TPR_0|, |FPR_1 − FPR_0|)：equalized odds 的违反程度"),
    ("PPV_diff", "sufficiency", True, 0.0, "predictive parity：PPV_1 − PPV_0"),
    ("NPV_diff", "sufficiency", True, 0.0, "NPV_1 − NPV_0"),
    ("cal_gap", "sufficiency", True, 0.0, "两条校准曲线之间的平均距离（分数层面的 sufficiency）"),
    ("acc_diff", "other", True, 0.0, "accuracy_1 − accuracy_0（overall accuracy equality）"),
    ("TE_ratio", "other", True, 1.0, "treatment equality：(FN/FP)_1 / (FN/FP)_0"),
]


def fairness_metrics(y, yhat, group, score=None):
    """
    计算两个群体之间的全部常用公平指标（以及每个群体自己的各个比例）。

    【思路】先对每个群体调用 confusion_rates，再把它们组合成差值 / 比值。指标按 Barocas, Hardt & Narayanan
        的三个准则分组（见 METRICS）：
            independence  Ŷ ⊥ A        只看批准率，不需要标签 y
            separation    Ŷ ⊥ A | Y    在"真正会还款 / 会违约的人"里比较 —— TPR、FPR
            sufficiency   Y ⊥ A | Ŷ    在"被批准 / 被拒绝的人"里比较 —— PPV、NPV；分数层面就是"校准曲线相同"
    【参数】
        y      0/1 真实标签（可以传入"记录下来的标签"，§7 会比较两者）
        yhat   0/1 决策
        group  0/1 群体
        score  可选：连续分数（概率）。给了就额外计算 cal_gap 和每个群体的 ECE。
    【返回】dict：
        "g0"/"g1"  两个群体各自的 confusion_rates
        以及 METRICS 里的每个名字 → 一个数
        如果给了 score，还有 "ece_0"、"ece_1"
    【边界】
        · DIR 在 sel_0 = 0 时是 nan；TE_ratio 在某个群体 FP = 0 时是 nan。
        · 比值和差值传达的信息不同：sel 从 0.10 到 0.05 是"差 0.05"也是"只有一半"。法律上的四分之五规则用比值。
    【可信联系】第 6 讲的缓解方法各自"盯住"其中一个指标；看一个指标变好时，要同时看别的指标怎么变。
    """
    y, yhat, group = np.asarray(y), np.asarray(yhat), np.asarray(group)
    r0 = confusion_rates(y[group == 0], yhat[group == 0])
    r1 = confusion_rates(y[group == 1], yhat[group == 1])
    out = {"g0": r0, "g1": r1}
    out["SPD"] = r1["sel"] - r0["sel"]
    out["DIR"] = _safe_div(r1["sel"], r0["sel"])
    out["EOD"] = r1["tpr"] - r0["tpr"]
    out["FPR_diff"] = r1["fpr"] - r0["fpr"]
    out["AOD"] = 0.5 * (out["FPR_diff"] + out["EOD"])
    out["EqOdds_max"] = max(abs(out["EOD"]), abs(out["FPR_diff"]))
    out["PPV_diff"] = r1["ppv"] - r0["ppv"]
    out["NPV_diff"] = r1["npv"] - r0["npv"]
    out["acc_diff"] = r1["acc"] - r0["acc"]
    te0, te1 = _safe_div(r0["fn"], r0["fp"]), _safe_div(r1["fn"], r1["fp"])
    out["TE_ratio"] = te1 / te0 if (te0 == te0 and te0 > 0) else float("nan")
    if score is not None:
        score = np.asarray(score)
        out["cal_gap"] = calibration_gap(y, score, group)
        out["ece_0"] = ece(y[group == 0], score[group == 0])
        out["ece_1"] = ece(y[group == 1], score[group == 1])
    else:
        out["cal_gap"] = float("nan")
    return out


def bootstrap_metrics(y, yhat, group, rng, score=None, n_boot=1000, alpha=0.05):
    """
    所有公平指标的 bootstrap 置信区间（一次重抽，同时算所有指标）。

    【思路】和第 1 讲的 bootstrap_ci 一样：有放回地重抽 n 个人，重算指标，取 2.5% 和 97.5% 分位数。
        区别是这里每次重抽都调用 fairness_metrics 一次，把所有指标都记下来 —— 同一组下标用于所有指标，
        既省时间，也保证不同指标的区间来自"同样的假想样本"。
        重抽的是**个人**，不是"每个群体分别重抽"：群体的人数也会随机波动，这正是真实审计里的情况。
    【参数】y, yhat, group, score：同 fairness_metrics；rng：随机数生成器；n_boot：重抽次数；alpha：1 − 置信水平
    【返回】dict：指标名 → (lo, hi)；也包括 "g0.tpr" 这样的单群体比例
    【边界】某次重抽里某个分母为 0 时该指标是 nan，用 np.nanquantile 跳过。
    【numpy 小知识】np.nanquantile(v, q) 计算分位数时忽略 nan。
    """
    n = len(y)
    names = [m[0] for m in METRICS]
    sub = ["sel", "tpr", "fpr", "ppv", "npv", "acc"]
    vals = {k: np.empty(n_boot) for k in names + [f"g{g}.{s}" for g in (0, 1) for s in sub]}
    for b in range(n_boot):
        idx = rng.integers(0, n, n)
        fm = fairness_metrics(y[idx], yhat[idx], group[idx], None if score is None else score[idx])
        for k in names:
            vals[k][b] = fm[k]
        for g in (0, 1):
            for s in sub:
                vals[f"g{g}.{s}"][b] = fm[f"g{g}"][s]
    out = {}
    for k, v in vals.items():
        if np.all(np.isnan(v)):
            out[k] = (float("nan"), float("nan"))
        else:
            out[k] = (float(np.nanquantile(v, alpha / 2)), float(np.nanquantile(v, 1 - alpha / 2)))
    return out


def wilson_ci(k, n, z=1.96):
    """
    一个比例 k/n 的 Wilson 置信区间（Wilson score interval）。

    【思路】最常见的"正态近似"区间 p̂ ± z·sqrt(p̂(1−p̂)/n) 在 n 小或 p̂ 接近 0/1 时很糟糕
        （k = 0 时区间宽度为 0，好像我们"确定"比例是 0）。Wilson 区间把 p 的不确定性放进方差里解方程，
        小样本时可靠得多，而且永远在 [0, 1] 之内：
            center = (p̂ + z²/2n) / (1 + z²/n)
            half   = z / (1 + z²/n) · sqrt(p̂(1−p̂)/n + z²/4n²)
    【参数】k：成功次数；n：试验次数；z：正态分位数（1.96 → 95%）
    【返回】(lo, hi)；n = 0 时返回 (nan, nan)
    【可信联系】交叉群体（§9）里每个格子可能只有几十个人，Wilson 区间让"这个格子的 TPR 有多不确定"一目了然。
    """
    if n == 0:
        return float("nan"), float("nan")
    p = k / n
    denom = 1.0 + z * z / n
    center = (p + z * z / (2 * n)) / denom
    half = z / denom * np.sqrt(p * (1 - p) / n + z * z / (4 * n * n))
    return max(0.0, float(center - half)), min(1.0, float(center + half))   # 去掉 −0.000 这样的浮点误差


# =============================================================================
# 3. 分数层面的三个准则（§4 不可能定理）
# =============================================================================
def _ecdf_gap(a, b):
    """
    两组样本的经验分布函数之间的最大距离（Kolmogorov–Smirnov 距离）：max_t |F_a(t) − F_b(t)|。

    【思路】F_a(t) = P(s ≤ t)。在所有出现过的值 t 处比较两个经验分布函数，取最大差。
        对公平来说它有一个直观含义：1 − F(t) 就是"阈值取 t 时的批准率"，所以 KS 距离 =
        **所有可能的阈值里，最大的那个批准率差距**。
    【numpy 小知识】np.searchsorted(sorted_a, t, side="right") = sorted_a 里 ≤ t 的元素个数。
    """
    if len(a) == 0 or len(b) == 0:
        return float("nan")
    a, b = np.sort(a), np.sort(b)
    t = np.concatenate([a, b])
    fa = np.searchsorted(a, t, side="right") / len(a)
    fb = np.searchsorted(b, t, side="right") / len(b)
    return float(np.max(np.abs(fa - fb)))


def max_dp_gap(score, group):
    """
    Independence 的违反程度：所有阈值下，两个群体批准率之差的最大值（= 两组分数分布的 KS 距离）。

    【思路】independence 要求 R ⊥ A：两个群体的分数**分布**完全相同。只比较平均分数不够（平均相同、分布可以不同）；
        KS 距离为 0 ⇔ 两个分布相同 ⇔ 无论阈值取多少，批准率都相等。
    【返回】0~1 之间的数；0 = independence 成立
    """
    return _ecdf_gap(score[group == 0], score[group == 1])


def max_sep_gap(score, y, group):
    """
    Separation 的违反程度：所有阈值下，TPR 差距和 FPR 差距中最大的那个。

    【思路】separation 要求 R ⊥ A | Y：在 y = 1 的人里两个群体的分数分布相同（任何阈值下 TPR 相等），
        在 y = 0 的人里也相同（FPR 相等）。所以分别在 y = 1、y = 0 里算 KS 距离，取较大者。
    """
    g1 = _ecdf_gap(score[(group == 0) & (y == 1)], score[(group == 1) & (y == 1)])
    g0 = _ecdf_gap(score[(group == 0) & (y == 0)], score[(group == 1) & (y == 0)])
    return max(g0, g1)


def calibration_gap(y, score, group, n_bins=10, min_count=30):
    """
    Sufficiency 的违反程度（分数层面）：在同一个分数区间里，两个群体的实际正例比例差多少，按人数加权平均。

    【思路】sufficiency 要求 Y ⊥ A | R：给定分数，群体标签不再提供关于 y 的任何信息。
        等价地说，两个群体的**校准曲线**（P(y = 1 | 分数) 随分数变化的曲线）重合。
        把分数分成 n_bins 个等宽区间，在每个区间里比较两个群体的正例比例，|差| 按区间人数加权。
        如果两个群体各自都 calibrated（P(y=1 | s, a) = s），校准曲线都是对角线，自然重合 ⇒ cal_gap ≈ 0。
    【参数】y、score、group；n_bins：区间数；min_count：一个区间里**每个群体**至少要有这么多人才参与比较
    【返回】加权平均的 |差|；没有任何区间满足 min_count 时返回 nan
    【边界】区间太细、人太少时，|差| 主要是抽样噪声（而且取绝对值会让噪声只往正方向累加）—— 所以设 min_count。
    """
    edges = np.linspace(0.0, 1.0, n_bins + 1)
    idx = np.clip(np.digitize(score, edges) - 1, 0, n_bins - 1)
    num, den = 0.0, 0
    for b in range(n_bins):
        m0 = (idx == b) & (group == 0)
        m1 = (idx == b) & (group == 1)
        if m0.sum() >= min_count and m1.sum() >= min_count:
            w = int(m0.sum() + m1.sum())
            num += w * abs(y[m0].mean() - y[m1].mean())
            den += w
    return num / den if den > 0 else float("nan")


def balance_stats(y, score, group):
    """
    Kleinberg, Mullainathan & Raghavan (2016) 的三个量：每个群体的 base rate p、
    "负类的平均分数" x = E[s | y = 0, a]（广义 FPR）、"正类的平均分数" t = E[s | y = 1, a]（广义 TPR）。

    【思路】
        · balance for the negative class：x_0 = x_1（真正会违约的人，在两个群体里得到的平均分数一样）
        · balance for the positive class：t_0 = t_1
        · calibration within groups ⇒ 每个群体内 Σ s = Σ y ⇒ (1 − p)·x + p·t = p ⇒ (1 − p)·x = p·(1 − t)
        这个恒等式（"KMR 直线"）就是不可能定理的核心：两个群体的 (x, t) 各自落在一条斜率由 p 决定的直线上。
    【返回】dict：group → {"p", "x", "t", "lhs" = (1 − p)·x, "rhs" = p·(1 − t)}
    """
    out = {}
    for g in (0, 1):
        m = group == g
        p = float(y[m].mean())
        x = float(score[m & (y == 0)].mean())
        t = float(score[m & (y == 1)].mean())
        out[g] = {"p": p, "x": x, "t": t, "lhs": (1 - p) * x, "rhs": p * (1 - t)}
    return out


# =============================================================================
# 4. 代理变量：群体信息藏在别的特征里吗？（§6）
# =============================================================================
def balanced_error_rate(a, a_hat):
    """
    平衡错误率 BER = [P(â = 0 | a = 1) + P(â = 1 | a = 0)] / 2。

    【思路】Feldman et al. (2015) 用它衡量"从其他特征能多准地猜出受保护属性"。为什么不用普通准确率？
        少数群体只占 30% 时，"全猜 group 0"就有 70% 准确率，却什么也没猜出来；BER 对两类一视同仁，
        这种"全猜一类"的分类器 BER = 0.5。BER = 0.5 ⇔ 完全猜不出；BER = 0 ⇔ 完美代理。
    【边界】某一类没有样本时返回 nan。
    """
    a, a_hat = np.asarray(a), np.asarray(a_hat)
    e1 = _safe_div(np.sum((a == 1) & (a_hat == 0)), np.sum(a == 1))
    e0 = _safe_div(np.sum((a == 0) & (a_hat == 1)), np.sum(a == 0))
    return 0.5 * (e1 + e0)


def predict_group(M_train, a_train, M_test, a_test, lam=1e-3):
    """
    用一组特征预测群体标签：返回测试集上的 AUC 和最小 BER。

    【思路】训练一个逻辑回归 a ~ M（第 1 讲的 LogisticModel），在**另一份**数据上评估，避免"在训练集上猜得准"的假象。
        BER 的阈值在训练集上选（让训练集 BER 最小），再用到测试集上。
        AUC 回答"能不能把两个群体排开"，BER 回答"最好的那个阈值下猜错多少"。
    【参数】M_train/M_test：特征矩阵；a_train/a_test：0/1 群体；lam：L2 正则化
    【返回】(auc, ber)
    【边界】特征和群体完全无关时 AUC ≈ 0.5、BER ≈ 0.5（有限样本会有一点波动）。
    【可信联系】Feldman et al. 的定理：如果最好的分类器的 BER 都高于某个界，这份数据上**任何**模型都不会有
        （四分之五规则意义下的）disparate impact；反过来，BER 低说明群体信息"藏"在特征里，去掉群体标签挡不住它。
    """
    model = LogisticModel(lam=lam).fit(M_train, a_train)
    s_tr, s_te = model.proba(M_train), model.proba(M_test)
    cands = np.quantile(s_tr, np.linspace(0.01, 0.99, 99))
    bers = [balanced_error_rate(a_train, (s_tr >= c).astype(int)) for c in cands]
    thr = float(cands[int(np.argmin(bers))])
    return auc(a_test, s_te), balanced_error_rate(a_test, (s_te >= thr).astype(int))


def same_risk_gap(yhat, group, p_true, n_bins=10):
    """
    "同样风险的人，批准率差多少"：按真实还款概率分层后，group 1 与 group 0 批准率之差的加权平均。

    【思路】总的批准率差距（SPD）混合了两件事：两个群体的风险分布不同（"世界的差异"），以及同样风险的人被区别对待。
        把人按 p_true 分成 n_bins 个分位数层，在每一层里比较批准率，再按每层 group 1 的人数加权平均。
        结果 ≈ "一个 group 1 申请人，比一个和 TA 风险相同的 group 0 申请人，被批准的概率低多少"。
        这正是 Corbett-Davies & Goel 主张的"把风险相同的人同样对待"的直接检验（需要知道真实风险 —— 只有合成数据里才有）。
    【参数】yhat：0/1 决策；group：0/1；p_true：真实还款概率；n_bins：分层数
    【返回】加权平均差（group 1 − group 0）；负数 = group 1 在同等风险下更难被批准
    【边界】某层里某个群体没人时跳过该层。
    """
    edges = np.quantile(p_true, np.linspace(0, 1, n_bins + 1))
    idx = np.clip(np.digitize(p_true, edges[1:-1]), 0, n_bins - 1)
    num, den = 0.0, 0
    for b in range(n_bins):
        m0 = (idx == b) & (group == 0)
        m1 = (idx == b) & (group == 1)
        if m0.sum() > 0 and m1.sum() > 0:
            num += m1.sum() * (yhat[m1].mean() - yhat[m0].mean())
            den += int(m1.sum())
    return num / den if den > 0 else float("nan")


# =============================================================================
# 5. 个体公平：相似的人被相似地对待吗？（§5）
# =============================================================================
def standardize(Z, ref=None):
    """
    按 ref 的均值 / 标准差做 z-score 标准化（ref 默认就是 Z 自己）。

    【思路】距离（"两个人有多相似"）对单位很敏感：收入以 k$ 计、负债率是 0~1 的比例，不标准化的话收入会主导距离。
    【边界】某列标准差为 0 时当作 1。
    """
    ref = Z if ref is None else ref
    mu, sd = ref.mean(axis=0), ref.std(axis=0)
    return (Z - mu) / np.where(sd > 0, sd, 1.0)


def within_group_percentiles(Z, group):
    """
    把每个特征换成"在本群体内部的百分位"（0~1）。

    【思路】这是另一种"相似"的定义：一个 group 1 申请人的信用历史处在 group 1 的第 80 百分位，
        就和处在 group 0 第 80 百分位的人"相似" —— 即使两人的历史年数差很多。
        如果你认为"group 1 历史短是结构性原因造成的（比如刚移民），不代表能力差"，这个度量更合理；
        如果你认为"观察到的特征就是能力"，原始特征上的距离更合理。Dwork et al. (2012) 讨论过这种跨群体的映射。
    【返回】和 Z 形状相同的矩阵，每列在每个群体内是 0~1 的百分位
    """
    P = np.empty_like(Z, dtype=float)
    for g in (0, 1):
        m = group == g
        nm = int(m.sum())
        for j in range(Z.shape[1]):
            r = np.argsort(np.argsort(Z[m, j], kind="stable"), kind="stable")
            P[m, j] = (r + 0.5) / nm
    return P


def knn_indices(Q, R, k, exclude_self=False, chunk=1000):
    """
    对 Q 的每一行，在 R 里找欧氏距离最近的 k 行（返回下标）。

    【思路】直接算 |Q| × |R| 的距离矩阵：‖q − r‖² = ‖q‖² + ‖r‖² − 2 q·r。为了不占太多内存，按 chunk 行分块算。
        exclude_self=True 用于 Q 和 R 是同一份数据时：把自己到自己的距离设成无穷大。
    【参数】Q：m × d；R：n × d；k：近邻个数；exclude_self：Q is R 时设为 True
    【返回】m × k 的下标矩阵（按距离从近到远）
    【边界】k 不能超过 R 的行数（exclude_self 时不能超过 n − 1）。
    【numpy 小知识】np.argpartition(D, k, axis=1)[:, :k] 找出每行最小的 k 个元素的位置（不完全排序，比 argsort 快），
        再对这 k 个排序。
    """
    out = np.empty((len(Q), k), dtype=int)
    rr = np.sum(R * R, axis=1)
    for s in range(0, len(Q), chunk):
        q = Q[s:s + chunk]
        D = np.sum(q * q, axis=1)[:, None] + rr[None, :] - 2.0 * q @ R.T
        if exclude_self:
            D[np.arange(len(q)), np.arange(s, s + len(q))] = np.inf
        part = np.argpartition(D, k, axis=1)[:, :k]
        order = np.argsort(np.take_along_axis(D, part, axis=1), axis=1, kind="stable")
        out[s:s + chunk] = np.take_along_axis(part, order, axis=1)
    return out


def consistency(Z, yhat, k=10):
    """
    Zemel et al. (2013) 的 consistency：1 − 平均 |ŷ_i − （i 的 k 个最近邻的 ŷ 的平均）|。

    【思路】个体公平的一个"不需要定义 L"的近似：如果相似的人被相似地对待，一个人的决策应该和 TA 的邻居差不多。
        1 = 每个人都和邻居的决策完全一致。注意"邻居"由 Z 上的欧氏距离决定 —— 选什么特征、怎么缩放，就是选"相似"的定义。
    【参数】Z：n × d 已标准化的特征（**不含**群体标签）；yhat：0/1 决策（也可以是连续分数）；k：近邻数
    【返回】0~1 之间的数
    """
    nb = knn_indices(Z, Z, k, exclude_self=True)
    return float(1.0 - np.mean(np.abs(yhat - yhat[nb].mean(axis=1))))


def twin_gap(Z, group, score, decision):
    """
    跨群体的"最近双胞胎"：对每个 group 1 的人，在 group 0 里找 Z 上最近的那个人，比较两人的分数和决策。

    【思路】这是 Dwork et al. "相似的人被相似地对待"的一个直接检验：如果相似度由 Z 定义，
        那么"最像你的那个 group 0 申请人"应该得到和你差不多的分数。返回平均的分数差、决策不一致的比例，
        以及平均距离（距离太大说明在这个度量下"找不到真正的双胞胎"）。
    【参数】Z：n × d 的特征（定义相似度的空间）；group；score：连续分数；decision：0/1 决策
    【返回】dict：mean_abs_score_diff, decision_disagree, mean_score_diff（group 1 − 双胞胎，带符号），mean_dist
    """
    i1 = np.where(group == 1)[0]
    i0 = np.where(group == 0)[0]
    nb = knn_indices(Z[i1], Z[i0], 1)[:, 0]
    twin = i0[nb]
    dist = np.sqrt(np.sum((Z[i1] - Z[twin]) ** 2, axis=1))
    return {
        "mean_abs_score_diff": float(np.mean(np.abs(score[i1] - score[twin]))),
        "mean_score_diff": float(np.mean(score[i1] - score[twin])),
        "decision_disagree": float(np.mean(decision[i1] != decision[twin])),
        "mean_dist": float(dist.mean()),
    }


if __name__ == "__main__":
    # 自检：几个可以手算的例子。
    y = np.array([1, 1, 0, 0, 1, 1, 0, 0])
    yhat = np.array([1, 0, 1, 0, 1, 1, 0, 0])
    g = np.array([0, 0, 0, 0, 1, 1, 1, 1])
    fm = fairness_metrics(y, yhat, g)
    print("自检 self-check")
    print(f"  group 0：sel = {fm['g0']['sel']:.2f}（应 0.50），TPR = {fm['g0']['tpr']:.2f}（应 0.50），"
          f"FPR = {fm['g0']['fpr']:.2f}（应 0.50），PPV = {fm['g0']['ppv']:.2f}（应 0.50）")
    print(f"  group 1：sel = {fm['g1']['sel']:.2f}（应 0.50），TPR = {fm['g1']['tpr']:.2f}（应 1.00），"
          f"FPR = {fm['g1']['fpr']:.2f}（应 0.00），PPV = {fm['g1']['ppv']:.2f}（应 1.00）")
    print(f"  SPD = {fm['SPD']:.2f}（应 0），EOD = {fm['EOD']:.2f}（应 0.50），AOD = {fm['AOD']:.2f}（应 0）")
    print(f"  Wilson CI(0/10) = ({wilson_ci(0, 10)[0]:.3f}, {wilson_ci(0, 10)[1]:.3f})（不应是 [0, 0]）")
    print(f"  KS 距离（相同样本）= {_ecdf_gap(np.arange(5.0), np.arange(5.0)):.3f}（应 0），"
          f"（不相交）= {_ecdf_gap(np.arange(5.0), np.arange(5.0) + 10):.3f}（应 1）")
    print(f"  BER（全猜 0）= {balanced_error_rate(g, np.zeros(8, dtype=int)):.2f}（应 0.50）")
