"""
=============================================================================
第 9 讲 · 工具模块：成员推断攻击的"实验台"（a test bench for membership inference）
=============================================================================

对应讲义：lectures/lecture09_privacy_attacks.md 第 4–8 节（03、04 号脚本导入本模块；02 号脚本用到其中的 Featurizer）

-----------------------------------------------------------------------------
【这个模块解决什么问题 What problem this module solves】
-----------------------------------------------------------------------------
成员推断（membership inference, MIA）问的是："这条记录 (x, y) 在不在模型的训练集里？"
要**公平地**比较各种攻击，需要一个"知道真相"的实验台：

    1. 一个候选池（pool）：P 条记录，来自第 1 讲的合成信贷世界。
    2. 在池子上训练 M 个模型，每个模型随机选一半记录（N = P/2）作为训练集。
       于是每条记录大约在一半的模型里是成员（IN）、在另一半里不是（OUT）。
       我们记下一个 M × P 的布尔矩阵 IN[m, i] = "记录 i 是否在模型 m 的训练集里"。
    3. 评估时轮流把每个模型当作"目标模型"（target），其余 M − 1 个模型当作攻击者自己训练的
       "影子模型"（shadow models）。对目标模型的每条记录打一个"像成员"的分数，再和真实的 IN 比较。

这正是 LiRA 论文（Carlini et al. 2022）的实验设计：一次训练 M 个模型，就能得到 M × P 个
（模型, 记录）评估样本 —— 足够多，才能可靠地测量"FPR = 0.1% 时的 TPR"这种极端区域的指标。

-----------------------------------------------------------------------------
【一个只对逻辑回归成立的小技巧：φ = (2y − 1)·z】
-----------------------------------------------------------------------------
LiRA 不直接用模型的概率，而是用"对真实标签的置信度"的 logit 变换（logit scaling）：
    φ = log( p_y / (1 − p_y) )，p_y = 模型给真实标签 y 的概率。
对二分类逻辑回归，p_1 = σ(z)，p_0 = 1 − σ(z) = σ(−z)。所以
    y = 1 时 φ = z；  y = 0 时 φ = −z；  合起来 φ = (2y − 1)·z。
而 log loss = −log p_y = log(1 + e^{−φ})。φ 越大 ⇔ loss 越小 ⇔ 模型对这条记录越"有把握"。
（深度网络的 softmax 输出没有这么简单，LiRA 论文用的是 φ = log p_y − log Σ_{y' ≠ y} p_{y'}，二分类时两者相同。）

-----------------------------------------------------------------------------
【约定 Conventions】
-----------------------------------------------------------------------------
    · 攻击分数 score：**越大越像成员**。
    · TPR = 被判为成员的真成员比例；FPR = 被判为成员的非成员比例（误报）。
    · 特征标准化用一份固定的"公开参考样本"（20000 人）的均值和标准差，而不是每个模型自己的训练集。
      这样所有模型共享同一个设计矩阵，只是参数 θ 不同 —— 计算更快，也让"模型之间的差别只来自训练集"。
      （第 1 讲的 LogisticModel 用训练集自己的统计量；这是一个无关紧要的简化，讲义 §4.4 有说明。）

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

import sys
from pathlib import Path

import numpy as np

# 把第 1 讲的目录加进模块搜索路径，然后就能导入合成信贷世界和逻辑回归。
# 【Python 小知识】Path(__file__).resolve() 是本文件的绝对路径；.parent.parent 是 code/；/ "lecture01" 拼接子目录。
#   sys.path 是 Python 查找模块的目录列表；insert(0, ...) 放在最前面，优先查找。
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "lecture01"))

from trust_data import LEGIT, make_credit, true_repay_prob  # noqa: E402,F401
from trust_tools import fit_logistic_newton, poly_features, print_header, sigmoid  # noqa: E402,F401


# =============================================================================
# 1. 特征：固定的"公开"标准化 + 可选多项式
# =============================================================================
class Featurizer:
    """
    把原始特征（第 1 讲的 6 列）变成设计矩阵 [1, 标准化后的特征（或其多项式）]。

    【思路】只用 5 个"决策时拿得到"的特征（LEGIT，不含泄露的 officer_flag）。标准化常数来自一份固定的
        参考样本（相当于"公开的人口统计"），所以同一个 Featurizer 对任何数据给出同样的变换。
        degree = 3 时有 55 个特征 + 截距：容量大、样本少时容易过拟合 —— 我们要攻击的"目标模型"。
    【参数】degree：多项式次数（1 = 普通逻辑回归）；n_ref、seed：参考样本的大小和种子
    【返回】调用 feat(X) 返回 n × (m + 1) 的设计矩阵（第 0 列全是 1）
    【边界】离群的输入（比如收入是平均的 5 倍）在 degree = 3 时会变成很大的数（z³），
        模型在那里会"外推"出极端的 logit —— 04 号脚本正是用这一点演示"离群者更容易被记住"。
    【Python 小知识】定义了 __call__ 方法的对象可以像函数一样调用：feat(X) 等价于 feat.__call__(X)。
    """

    def __init__(self, degree=1, n_ref=20000, seed=900):
        self.degree = degree
        ref = make_credit(n_ref, np.random.default_rng(seed))
        Z = ref["X"][:, LEGIT]
        self.mu1, self.sd1 = Z.mean(axis=0), Z.std(axis=0)
        if degree > 1:
            P = poly_features((Z - self.mu1) / self.sd1, degree)
            self.mu2, self.sd2 = P.mean(axis=0), P.std(axis=0)

    def __call__(self, X):
        Z = (X[:, LEGIT] - self.mu1) / self.sd1
        if self.degree > 1:
            Z = (poly_features(Z, self.degree) - self.mu2) / self.sd2
        return np.column_stack([np.ones(len(Z)), Z])


# =============================================================================
# 2. 训练很多个模型 Training many models
# =============================================================================
def true_label_logit(theta, A, y):
    """
    φ = (2y − 1)·(Aθ)：模型对**真实标签**的置信度的 logit（见模块开头）。

    【参数】theta：参数；A：设计矩阵；y：0/1 标签
    【返回】长度 n 的数组。φ > 0 ⇔ 模型把这条记录分对了。
    """
    return (2.0 * y - 1.0) * (A @ theta)


def loss_from_phi(phi):
    """log loss = log(1 + e^{−φ})，用 np.logaddexp 计算，φ 很负时也不会上溢。"""
    return np.logaddexp(0.0, -phi)


def train_population(A, y, n_models, n_in, lam, rng, copies=None):
    """
    在候选池上训练 n_models 个模型，每个模型随机选 n_in 条记录做训练集。

    【思路】这是 LiRA 式实验的核心（模块开头第 2 步）。每个模型的训练集是一次"不放回抽样"
        rng.permutation(P)[:n_in]，所以训练集大小固定；每条记录平均在一半的模型里是成员。
        训练完记下它对**所有** P 条记录的 φ —— 成员和非成员都要，因为攻击者对两者都要打分。
    【参数】
        A, y       候选池的设计矩阵与标签（P 条）
        n_models   模型个数 M
        n_in       每个模型的训练集大小（通常 P // 2）
        lam        L2 正则化强度（> 0）
        rng        np.random.Generator
        copies     可选，长度 P 的正整数数组：记录 i 被选中时在训练集里出现 copies[i] 次
                   （模拟"重复的数据"，04 号脚本用；None = 每条 1 次）
    【返回】(IN, PHI, THETA)
        IN     M × P 布尔矩阵，IN[m, i] = 记录 i 是否在模型 m 的训练集里
        PHI    M × P 浮点矩阵，PHI[m, i] = 模型 m 对记录 i 的 φ
        THETA  M × d 参数矩阵
    【边界】copies 让训练集的实际行数变多（重复的行），但"成员"仍然按**唯一的记录**计。
    【numpy 小知识】np.repeat(idx, k) 把 idx 的第 j 个元素重复 k[j] 次，例如 repeat([5, 7], [1, 3]) = [5, 7, 7, 7]。
    """
    P = len(y)
    IN = np.zeros((n_models, P), dtype=bool)
    PHI = np.zeros((n_models, P))
    THETA = np.zeros((n_models, A.shape[1]))
    for m in range(n_models):
        idx = rng.permutation(P)[:n_in]
        IN[m, idx] = True
        rows = idx if copies is None else np.repeat(idx, copies[idx])
        theta = fit_logistic_newton(A[rows], y[rows], lam)
        THETA[m] = theta
        PHI[m] = true_label_logit(theta, A, y)
    return IN, PHI, THETA


# =============================================================================
# 3. ROC 与"低 FPR 下的 TPR"
# =============================================================================
def roc_curve(scores, is_member):
    """
    ROC 曲线：把阈值从 +∞ 降到 −∞，记录每个阈值下的 (FPR, TPR)。

    【思路】按分数从高到低排序，逐个"纳入"为预测成员，累计真成员数（TP）和非成员数（FP）。
        分数相同的记录必须**一起**纳入（同一个阈值无法把它们分开），所以只保留每组相同分数的最后一个位置。
    【参数】scores：越大越像成员；is_member：0/1 或布尔数组
    【返回】(fpr, tpr)：从 (0, 0) 开始、到 (1, 1) 结束的两个数组
    【边界】必须同时有成员和非成员，否则除以 0（调用方保证）。
    【numpy 小知识】np.r_[a, b] 把标量 / 数组首尾拼接成一个一维数组；kind="stable" 让相同的分数保持原来的顺序，
        结果可复现。
    """
    lab = np.asarray(is_member, dtype=float)
    order = np.argsort(-scores, kind="stable")
    s, l = scores[order], lab[order]
    tp, fp = np.cumsum(l), np.cumsum(1.0 - l)
    last = np.r_[s[1:] != s[:-1], True]               # 每组相同分数的最后一个位置
    tpr = np.r_[0.0, tp[last] / l.sum()]
    fpr = np.r_[0.0, fp[last] / (1.0 - l).sum()]
    return fpr, tpr


def tpr_at_fpr(fpr, tpr, alpha):
    """
    在 FPR ≤ alpha 的所有阈值里，能达到的最大 TPR。

    【思路】这是 Carlini et al.（2022）主张的指标：隐私是"最坏情况"的问题 —— 一个攻击只要能**很有把握地**
        认出一小部分人，对这些人来说就是真实的伤害；而 AUC、准确率是对所有阈值、所有人的平均。
    【边界】alpha 小于 1 / 非成员数时，只有 FPR = 0 的点可用（只能报告"零误报时认出了多少人"）。
        比较时要看噪声地板：完全没有信号的攻击，TPR@α 的期望约等于 α。
    """
    return float(np.max(tpr[fpr <= alpha + 1e-12]))


def attack_metrics(scores, is_member):
    """
    一个攻击的四个汇总数字：AUC、balanced accuracy、TPR@1% FPR、TPR@0.1% FPR。

    【思路】
        AUC         = ROC 曲线下面积（梯形法，相同分数的平局算一半）
        bal_acc     = 最好的阈值下的 (TPR + 1 − FPR)/2 = 0.5 + advantage/2，advantage = max(TPR − FPR)
                      （第 1 讲 §5.4 的 advantage；成员和非成员各一半时就是攻击准确率）
        tpr_1、tpr_01 = 低 FPR 区域的 TPR（见 tpr_at_fpr）
    【返回】dict
    【Python 小知识】梯形面积公式 Σ (x_{k+1} − x_k)(y_{k+1} + y_k)/2；不用 np.trapz / np.trapezoid，
        因为前者在 numpy 2 里被改名，写成显式求和在新旧版本里都能运行。
    """
    fpr, tpr = roc_curve(scores, is_member)
    area = float(np.sum((fpr[1:] - fpr[:-1]) * (tpr[1:] + tpr[:-1]) / 2.0))
    return {"auc": area,
            "bal_acc": float(0.5 + np.max(tpr - fpr) / 2.0),
            "tpr_1": tpr_at_fpr(fpr, tpr, 0.01),
            "tpr_01": tpr_at_fpr(fpr, tpr, 0.001)}


def noise_floor(is_member, rng, reps=50):
    """
    噪声地板：一个**完全没有信号**的攻击（随机分数）在同样多的成员 / 非成员上得到的四个指标。

    【思路】第 1 讲 §5.4 的教训：凡是"挑最好的阈值"的指标（advantage、bal_acc），有限样本上纯噪声也会挑出一点假优势；
        TPR@0.1% 在零信号时的期望约是 0.001，但样本少时波动很大。重复 reps 次，返回平均值和 95% 分位数。
    【返回】(mean_dict, q95_dict)
    """
    rows = [attack_metrics(rng.random(len(is_member)), is_member) for _ in range(reps)]
    keys = rows[0].keys()
    mean = {k: float(np.mean([r[k] for r in rows])) for k in keys}
    q95 = {k: float(np.quantile([r[k] for r in rows], 0.95)) for k in keys}
    return mean, q95


def metrics_header(name_width=34):
    """表头，和 metrics_row 配套。"""
    return f"{'attack':<{name_width}}{'AUC':>7}{'bal acc':>9}{'TPR@1%FPR':>11}{'TPR@0.1%FPR':>13}"


def metrics_row(name, m, name_width=34):
    """把 attack_metrics 的结果格式化成一行。TPR 用百分数，低 FPR 区域的差别才看得清。"""
    return (f"{name:<{name_width}}{m['auc']:>7.3f}{m['bal_acc']:>9.3f}"
            f"{100 * m['tpr_1']:>10.2f}%{100 * m['tpr_01']:>12.2f}%")


# =============================================================================
# 4. 攻击 Attacks（每个函数返回"越大越像成员"的分数）
# =============================================================================
def gap_scores(phi):
    """
    "Gap attack"（第 2022 年作业 HW4 的 naive baseline）：分对了就猜成员，分错了就猜非成员。

    【思路】分数只有 0 / 1 两个值，所以 ROC 只有一个拐点：TPR = 训练准确率，FPR = 测试准确率。
        它的 advantage = 训练准确率 − 测试准确率，正是"过拟合程度"本身（Yeom et al. 2018）。
    【边界】只有两个分数值 → 无法在低 FPR 区域工作（FPR 要么 0、要么等于测试准确率）。
    """
    return (phi > 0).astype(float)


def loss_scores(phi):
    """
    Loss-threshold attack（Yeom et al. 2018；第 1 讲 §5.4）：loss 越低越像成员，score = −loss。

    【思路】一个**全局**阈值：对所有记录用同一把尺子。它的弱点是分不清"容易的记录"（谁来训练 loss 都低）
        和"被记住的成员"（只因为在训练集里 loss 才低）。
    """
    return -loss_from_phi(phi)


def gradnorm_scores(phi, A):
    """
    一个"白盒"分数：单个样本的梯度范数 ‖∇_θ ℓ‖，越小越像成员。

    【思路】逻辑回归的单样本梯度是 (p − y)·a，所以 ‖∇ℓ‖ = |p − y|·‖a‖ = σ(−φ)·‖a‖：
        它 = "loss 的信号"（σ(−φ) 和 loss 单调对应）× "输入有多不寻常"（‖a‖）。
        白盒攻击者拿到参数 θ 才能算它。Sablayrolles et al.（2019）证明：在他们的假设下，
        最优攻击只依赖 loss，所以白盒信息不会带来额外好处 —— 这里检验一个简单的白盒特征是否真的没帮助。
    【返回】−log‖∇ℓ‖（取 log 避免极小的数值；单调变换不改变 ROC）
    """
    log_sig_neg = -np.logaddexp(0.0, phi)             # log σ(−φ)，数值稳定
    return -(log_sig_neg + np.log(np.linalg.norm(A, axis=1)))


def _in_out_stats(PHI, IN, shadow_ids):
    """
    在影子模型上估计每条记录的 IN / OUT 分布：均值、方差、个数。内部使用。

    【思路】对记录 i，IN 样本 = 影子模型里把 i 当成员的那些 φ；OUT 样本 = 没用 i 训练的那些 φ。
        每类少于 2 个样本时方差没有定义：用所有记录的平均方差代替；少于 1 个样本时均值也没有定义：
        用"OUT 均值 + 全体记录 IN − OUT 均值差的平均"代替（反之亦然）。影子模型少（比如 4 个）时才会发生。
    【返回】dict：mu_in, mu_out, var_in, var_out（逐记录），以及全局的 gvar_in, gvar_out
    【numpy 小知识】(Pm * Im).sum(axis=0) 是"只把 IN 的那些 φ 加起来"：布尔矩阵 Im 参与乘法时 True = 1、False = 0。
    """
    Pm, Im = PHI[shadow_ids], IN[shadow_ids]
    Om = ~Im
    n_in, n_out = Im.sum(axis=0), Om.sum(axis=0)
    mu_in = (Pm * Im).sum(axis=0) / np.maximum(n_in, 1)
    mu_out = (Pm * Om).sum(axis=0) / np.maximum(n_out, 1)
    both = (n_in >= 1) & (n_out >= 1)
    shift = float(np.mean(mu_in[both] - mu_out[both]))
    mu_in = np.where(n_in >= 1, mu_in, mu_out + shift)
    mu_out = np.where(n_out >= 1, mu_out, mu_in - shift)
    ss_in = (((Pm - mu_in) ** 2) * Im).sum(axis=0)
    ss_out = (((Pm - mu_out) ** 2) * Om).sum(axis=0)
    ok_in, ok_out = n_in >= 2, n_out >= 2
    var_in = np.where(ok_in, ss_in / np.maximum(n_in - 1, 1), 0.0)
    var_out = np.where(ok_out, ss_out / np.maximum(n_out - 1, 1), 0.0)
    gvar_in = float(np.mean(var_in[ok_in]))
    gvar_out = float(np.mean(var_out[ok_out]))
    var_in = np.where(ok_in, var_in, gvar_in)
    var_out = np.where(ok_out, var_out, gvar_out)
    return {"mu_in": mu_in, "mu_out": mu_out, "var_in": var_in, "var_out": var_out,
            "gvar_in": gvar_in, "gvar_out": gvar_out}


def default_shadows(n_models, t, k=None):
    """
    目标模型 t 的影子模型编号：默认是除 t 以外的全部；给定 k 时取 t 之后的 k 个（循环）。

    【思路】"取 t 之后的 k 个"保证不同的 t 用不同的影子集合，同时完全确定、可复现。
    """
    if k is None:
        return np.array([m for m in range(n_models) if m != t])
    return np.array([(t + 1 + j) % n_models for j in range(k)])


def calibrated_loss_scores(PHI, IN, t, shadow_ids=None):
    """
    "难度校准"的 loss（difficulty calibration；Watson et al. 2022，也接近 Sablayrolles et al. 2019 的 MAST）：
        score = −(目标模型上的 loss − 这条记录在 OUT 影子模型上的平均 loss)。

    【思路】同样是 loss = 0.05，对一条"谁训练都会 0.05"的容易记录毫无意义，对一条"不在训练集里时平均 1.2"的
        困难记录则是强烈的成员信号。减去每条记录自己的"基线 loss"，就把全局阈值变成了**逐条记录的阈值**。
        只用 OUT 模型 → 是 offline 攻击：不需要为每条要攻击的记录专门训练 IN 模型。
    """
    if shadow_ids is None:
        shadow_ids = default_shadows(len(PHI), t)
    Pm, Om = PHI[shadow_ids], ~IN[shadow_ids]
    base = (loss_from_phi(Pm) * Om).sum(axis=0) / np.maximum(Om.sum(axis=0), 1)
    return -(loss_from_phi(PHI[t]) - base)


def lira_scores(PHI, IN, t, online=True, fixed_var=False, shadow_ids=None, stat=None):
    """
    LiRA（Likelihood Ratio Attack，Carlini et al. 2022）：对每条记录分别拟合 IN / OUT 两个高斯分布，做似然比检验。

    【思路】
        · 把成员推断看成假设检验：H0 "记录不在训练集里" vs H1 "在"。Neyman–Pearson 引理：在任何给定的 FPR 下，
          最强（TPR 最高）的检验是似然比 Λ = p(观测 | H1) / p(观测 | H0)。
        · 观测 = 目标模型对这条记录的 φ。p(φ | H1)、p(φ | H0) 是"在随机训练集上训练的模型"给出的分布 ——
          影子模型正是在模拟它们。LiRA 假设它们是高斯分布（φ 经过 logit 变换后大致是高斯的，概率 p 本身不是）。
        · online：用 IN 和 OUT 两个高斯，score = log N(φ; μ_in, σ_in²) − log N(φ; μ_out, σ_out²)。
          要求影子模型里有一半**包含**这条记录 —— 攻击一条新记录就要重新训练影子模型。
        · offline：只用 OUT 高斯，做单边检验：score = (φ − μ_out)/σ_out（φ 比"不在时"高出几个标准差）。
          影子模型可以提前一次训练好，适用于任何新记录。
        · fixed_var：所有记录共用一个方差（全局平均）。影子模型很少时逐条估计的方差噪声太大，固定方差更稳。
    【参数】
        PHI, IN     train_population 的输出
        t           目标模型的编号
        online      True = online LiRA；False = offline
        fixed_var   是否用全局方差
        shadow_ids  影子模型编号（默认除 t 以外的全部）
        stat        可选，用来代替 PHI 的统计量矩阵（例如概率 p 本身，用来演示"为什么要 logit 变换"）
    【返回】长度 P 的分数数组
    【边界】σ 加了 1e-6 的下限：某条记录在所有影子模型里 φ 都一样时，方差为 0，除以 0 会出错。
    【可信联系】第 10 讲：差分隐私保证 Λ 对任何观测都不超过 e^ε —— 正是限制了这个似然比。
    """
    S = PHI if stat is None else stat
    if shadow_ids is None:
        shadow_ids = default_shadows(len(S), t)
    st = _in_out_stats(S, IN, shadow_ids)
    if fixed_var:
        sd_in = np.full(S.shape[1], np.sqrt(st["gvar_in"])) + 1e-6
        sd_out = np.full(S.shape[1], np.sqrt(st["gvar_out"])) + 1e-6
    else:
        sd_in, sd_out = np.sqrt(st["var_in"]) + 1e-6, np.sqrt(st["var_out"]) + 1e-6
    obs = S[t]
    z_out = (obs - st["mu_out"]) / sd_out
    if not online:
        return z_out
    z_in = (obs - st["mu_in"]) / sd_in
    # log N(x; μ, σ²) = −½((x − μ)/σ)² − log σ − ½ log 2π；两项相减时常数抵消
    return (-0.5 * z_in ** 2 - np.log(sd_in)) - (-0.5 * z_out ** 2 - np.log(sd_out))


def pooled_attack(score_fn, IN, targets):
    """
    对多个目标模型分别打分，再把所有（目标模型, 记录）对拼在一起，算一条 ROC。

    【思路】每个目标模型贡献 P 个评估样本（约一半成员）。拼在一起后非成员有几万个，
        才能可靠地测量 FPR = 0.1% 附近的 TPR（0.1% 只对应几十个误报）。
    【参数】score_fn：函数 t → 长度 P 的分数；IN：成员矩阵；targets：目标模型编号列表
    【返回】(scores, labels) 两个拼接后的一维数组
    【Python 小知识】函数可以作为参数传进来（score_fn），调用方常用 lambda t: ... 写一个"一次性"的小函数。
    """
    scores = np.concatenate([score_fn(t) for t in targets])
    labels = np.concatenate([IN[t] for t in targets])
    return scores, labels


# =============================================================================
# 5. Shokri et al.（2017）的影子模型攻击
# =============================================================================
def _attack_design(u, mu, sd):
    """攻击模型的输入特征：标准化后的 u 的 1、2、3 次方（加截距）。内部使用。"""
    v = (u - mu) / sd
    return np.column_stack([np.ones(len(v)), v, v ** 2, v ** 3])


class ShokriAttack:
    """
    Shokri et al.（2017）的黑盒影子模型攻击（也是 2022 年作业 HW4 要实现的攻击）。

    【思路】
        1. 攻击者有一份和目标训练集**不相交**、但同分布的数据 S。
        2. 反复把 S 随机分成 S_in / S_out，只用 S_in 训练一个影子模型 g（和目标模型同样的算法）。
        3. 记录 g 对 S_in 和 S_out 每条记录的输出向量和真实标签，加上"in = 1 / out = 0"的标签，
           组成一个新的"攻击训练集"。
        4. **按类别**训练攻击模型 m_y：输入 g 的输出，输出"是成员的概率"。
        5. 攻击目标模型 f 时，把 f 的输出喂给 m_y —— 赌的是 f 在成员 / 非成员上的行为和影子模型一样。
    【我们的简化】二分类的输出向量 (1 − p, p) 只有一个自由度，所以攻击模型的输入是一维的
        （用 φ，比 p 数值上更好用）；攻击模型是 φ 的三次多项式逻辑回归，而不是 HW4 里的小 MLP ——
        对一维输入，三次曲线已经足够灵活。
    【用法】
        atk = ShokriAttack().fit(PHI_shadow, IN_shadow, y_shadow)
        score = atk.score(phi_target, y_target)
    【可信联系】Salem et al.（2019，ML-Leaks）发现一个影子模型、甚至不同分布的数据也够用，
        而且"对最大置信度设一个阈值"几乎一样好 —— 影子模型学到的主要就是一个阈值。03 号脚本检验这一点。
    """

    def __init__(self, lam=1e-3):
        self.lam = lam

    def fit(self, PHI, IN, y):
        """
        【参数】PHI、IN：影子模型在影子数据上的 φ 和成员矩阵（train_population 的输出）；y：影子数据的标签
        【返回】self
        """
        self.models = {}
        for c in (0, 1):
            cols = y == c
            u = PHI[:, cols].ravel()                  # 所有影子模型、所有这一类记录的 φ
            b = IN[:, cols].ravel().astype(float)     # 对应的 in / out 标签
            mu, sd = float(u.mean()), float(u.std())
            theta = fit_logistic_newton(_attack_design(u, mu, sd), b, self.lam)
            self.models[c] = (theta, mu, sd)
        return self

    def score(self, phi, y):
        """返回"是成员"的 logit（越大越像成员）。【边界】按每条记录的真实类别选用对应的攻击模型。"""
        out = np.zeros(len(phi))
        for c in (0, 1):
            m = y == c
            theta, mu, sd = self.models[c]
            out[m] = _attack_design(phi[m], mu, sd) @ theta
        return out


# =============================================================================
# 6. 逐条记录的风险 Per-record risk
# =============================================================================
def ks_advantage(a_in, a_out):
    """
    单边 Kolmogorov–Smirnov 统计量：max_τ [P_in(φ > τ) − P_out(φ > τ)]。

    【思路】如果攻击者**专门针对这一个人**，并且知道 TA 在 / 不在训练集时模型输出的分布，那么用一个阈值能达到的
        最大 TPR − FPR 就是这个量 —— 它是"这个人的成员身份泄露了多少"的一个直接度量。
        0 = 两种世界完全分不开；1 = 一看就知道。
    【参数】a_in：IN 世界里的 φ 样本；a_out：OUT 世界里的 φ 样本
    【返回】[0, 1] 之间的数
    【边界】样本有限时，即使两个分布完全相同也会得到正数（约 1/√样本数 的量级）—— 要和噪声地板比较。
    【numpy 小知识】np.searchsorted(sorted_a, v, side="right") = sorted_a 里 ≤ v 的元素个数，
        除以长度就是经验分布函数 F(v)。
    """
    a_in, a_out = np.sort(a_in), np.sort(a_out)
    grid = np.r_[a_in, a_out]
    F_in = np.searchsorted(a_in, grid, side="right") / len(a_in)
    F_out = np.searchsorted(a_out, grid, side="right") / len(a_out)
    return float(max(0.0, np.max(F_out - F_in)))


def per_record_ks(PHI, IN, rng=None):
    """
    每条记录的 KS 优势（见 ks_advantage）；给 rng 时额外返回"打乱 IN 标签"后的噪声地板。

    【返回】ks（长度 P）；给 rng 时返回 (ks, ks_null)
    """
    P = PHI.shape[1]
    ks = np.array([ks_advantage(PHI[IN[:, i], i], PHI[~IN[:, i], i]) for i in range(P)])
    if rng is None:
        return ks
    null = []
    for i in range(P):
        lab = IN[rng.permutation(len(PHI)), i]
        null.append(ks_advantage(PHI[lab, i], PHI[~lab, i]))
    return ks, np.array(null)


if __name__ == "__main__":
    # 自检：几个恒等式和边界情况。
    print("自检 self-check")
    rng = np.random.default_rng(0)
    lab = np.r_[np.ones(500), np.zeros(500)]
    perfect = attack_metrics(lab + 0.0, lab)
    print(f"  完美的攻击：AUC = {perfect['auc']:.3f}，bal acc = {perfect['bal_acc']:.3f}，"
          f"TPR@0.1% = {perfect['tpr_01']:.3f}（应为 1, 1, 1）")
    mean, q95 = noise_floor(lab, rng, reps=20)
    print(f"  随机分数：AUC ≈ {mean['auc']:.3f}，bal acc ≈ {mean['bal_acc']:.3f}（> 0.5：挑最好的阈值带来的假优势）")
    feat = Featurizer(degree=3)
    d = make_credit(5, np.random.default_rng(1))
    A = feat(d["X"])
    theta = rng.normal(size=A.shape[1]) * 0.1
    phi = true_label_logit(theta, A, d["y"])
    p = sigmoid(A @ theta)
    direct = -(d["y"] * np.log(p) + (1 - d["y"]) * np.log(1 - p))
    print(f"  设计矩阵形状 {A.shape}（应为 (5, 56)）；log loss 两种算法的最大差 = "
          f"{np.max(np.abs(loss_from_phi(phi) - direct)):.2e}（应约为 0）")
    print(f"  ks_advantage(相同样本) = {ks_advantage(np.arange(10.0), np.arange(10.0)):.3f}（应为 0）；"
          f"完全分开 = {ks_advantage(np.arange(10.0) + 100, np.arange(10.0)):.3f}（应为 1）")
