"""
=============================================================================
第 14 讲 · 代码 3：古德哈特定律的四种机制
=============================================================================

对应讲义：lectures/lecture14_style_surrogates.md 第 4 节

-----------------------------------------------------------------------------
【这个文件要解决什么问题？】
-----------------------------------------------------------------------------
"一个指标一旦成为目标，就不再是好的指标。"Manheim & Garrabrant（2018）把古德哈特定律分成四种机制，
每一种都对应偏好学习里的一类失败。这个文件给每一种机制一个最小的模拟：

    1. 回归型（regressional）：代理 = 真实 + 噪声。按代理挑出的"最好的"，真实值总是低于代理值，
       挑得越狠，差得越多（"赢家的诅咒"）。
    2. 极端型（extremal）：在平常的范围里，代理和真实值高度相关；可在极端的地方，两者的关系变了。
       优化把我们推到从没见过的极端（第 8 讲：要点越多越好 → 要点堆满）。
    3. 因果型（causal）：代理和真实值相关，是因为它们有共同的原因；直接去改代理，真实值不会变
       （"把回答写长"不会让它更好）。
    4. 对抗型（adversarial）：被评价的一方知道指标，就会去"刷"它（第 8 讲的奖励黑客，第 14 讲代码 2 的刷长度）。

-----------------------------------------------------------------------------
【运行方式】
-----------------------------------------------------------------------------
    python3 03_goodhart.py        # 约 1 秒

只需要 numpy。依赖本目录的 style_tools.py（只用打印工具）。
"""

import numpy as np

from style_tools import pad, print_header


def regressional(rng):
    print_header("1. 回归型：代理 = 真实 + 噪声，挑得越狠，差得越多")
    n = 1_000_000
    true = rng.normal(0, 1, n)
    proxy = true + rng.normal(0, 1, n)                 # 奖励模型 = 真实质量 + 同样大小的误差
    print(f"{n} 个候选回答：真实质量 ~ N(0, 1)，奖励模型的分数 = 真实质量 + N(0, 1) 的误差（相关系数 {np.corrcoef(true, proxy)[0, 1]:.2f}）。")
    print("按奖励模型挑出最好的一部分：\n")
    print(f"  {pad('挑出前', 8)} {pad('奖励模型的平均分', 17, 'right')} {pad('真实质量的平均值', 17, 'right')} {pad('差距', 7, 'right')}")
    order = np.argsort(-proxy)
    for frac in [0.5, 0.1, 0.01, 0.001, 0.0001]:
        k = int(n * frac)
        sel = order[:k]
        print(f"  {frac:>8.2%} {proxy[sel].mean():>17.3f} {true[sel].mean():>17.3f} {proxy[sel].mean() - true[sel].mean():>7.3f}")
    print("""
观察：
  · 被挑中的回答，奖励模型的分数总是高估了真实质量：分数高，一部分是因为真的好，一部分是因为误差恰好是正的。
    挑得越狠（越接近“最高分”），误差的贡献越大 —— 差距越拉越大（这里正好是一半：两部分方差相等）。
  · 但注意：真实质量仍然随挑选变好！回归型古德哈特不会让结果变差，只会让我们**高估**结果。
    Best-of-n 的过度优化（第 8 讲）里还有别的机制在起作用。""")


def extremal(rng):
    print_header("2. 极端型：在平常的范围里相关，在极端的地方关系变了")
    # 真实质量随"要点个数"先升后降：最好是 3 个，再多就是重复啰嗦（第 8 讲的边际递减）
    def value(x):
        return np.where(x <= 3, x, 3 - 1.5 * (x - 3))
    data = rng.poisson(1.5, 5000).astype(float)              # 平常的回答：要点大多 0~3 个
    corr = np.corrcoef(data, value(data))[0, 1]
    print(f"平常的回答里，“要点个数”和真实质量的相关系数 {corr:.2f}（明显正相关）。用线性回归学到的“代理”：")
    slope, intercept = np.polyfit(data, value(data), 1)
    print(f"    代理 = {intercept:+.2f} + {slope:.2f} × 要点个数")
    print("\n  优化代理：允许生成的回答要点越来越多（优化的力度越来越大）：\n")
    print(f"  {pad('要点个数', 9)} {pad('代理', 7, 'right')} {pad('真实质量', 9, 'right')} {pad('训练数据里这么多的比例', 23, 'right')}")
    for x in [1, 2, 3, 4, 5, 6, 8]:
        print(f"  {x:>9d} {intercept + slope * x:>7.2f} {float(value(np.array(x))):>9.2f} {np.mean(data >= x):>23.4f}")
    print("""
观察：
  · 在数据大多覆盖的范围里（0~3 个），“越多越好”是对的，线性的代理抓住了这个趋势
    （少数 4 个以上的回答让它学得偏平，但方向是对的）。
  · 优化把回答推到了数据里几乎没有的区域（6 个、8 个），代理一路上涨，真实质量一路下跌 ——
    第 8 讲线性奖励模型“要点越多越好”的过度优化，就是这种机制。
  · 应对：限制优化离数据有多远（KL 约束、第 8 讲）；在优化到达的地方收集新数据（在线收集、第 9、10 讲）。""")


def causal(rng):
    print_header("3. 因果型：共同的原因造成了相关，改代理不会改真实值")
    n = 20000
    effort = rng.normal(0, 1, n)                              # "认真程度"：共同的原因
    length = 1000 + 300 * effort + 150 * rng.normal(size=n)   # 认真的回答更长
    quality = effort + 0.5 * rng.normal(size=n)               # 认真的回答更好（长度本身对质量没有作用）
    slope = np.polyfit(length, quality, 1)[0]
    print(f"观察数据里，长度和质量的相关系数 {np.corrcoef(length, quality)[0, 1]:.2f}；回归斜率：每多 100 字，质量 +{100 * slope:.2f}。")
    # 干预：把所有回答都"加长 500 字"（例如加上客套话），认真程度不变
    length2 = length + 500
    quality2 = quality - 0.0                                  # 质量不变（甚至可能因为啰嗦而变差）
    print(f"干预：把每个回答都加长 500 字（只加客套话）。按回归的预测，质量应该 +{500 * slope:.2f}；")
    print(f"实际的平均质量变化：{quality2.mean() - quality.mean():+.2f}。平均长度从 {length.mean():.0f} 变成 {length2.mean():.0f}。")
    print("""
观察：
  · 长度和质量相关，是因为“认真”同时导致了长和好。在观察数据里学到的“长度 → 质量”不是因果关系。
  · 一个只看长度的奖励模型（或评委），会奖励“加长”这个干预 —— 而这个干预对真实质量毫无帮助。
    这就是代码 01 演示 1 和演示 2 的区别：长度到底是“共同原因的结果”还是“质量的一部分”，要靠因果推断回答。
  · 应对：用干预数据（同一个回答的长短两个版本）估计因果效应；在建模时加入风格变量并做敏感性分析。""")


def adversarial(rng):
    print_header("4. 对抗型：被评价的一方会去“刷”指标")
    print("一个被评价的模型（或团队）有 1 份预算，可以花在“真正提高质量”上，也可以花在“刷指标”上（例如堆格式、写长）。")
    print("  · 花 q 在质量上：真实质量 +q，指标 +q")
    print("  · 花 g 在刷指标上：真实质量 −0.2g（啰嗦有害），指标 +3g（刷指标便宜得多）")
    print("评价方以概率 p 做人工审核：审核时刷出来的部分不算分、还要罚 2g。被评价的一方选择让“期望指标”最大的分配：\n")
    print(f"  {pad('审核概率 p', 11)} {pad('最优的刷指标比例', 17, 'right')} {pad('期望指标', 9, 'right')} {pad('真实质量', 9, 'right')}")
    grid = np.linspace(0, 1, 101)
    for p in [0.0, 0.2, 0.3, 0.5, 0.8]:
        g = grid
        q = 1 - g
        metric = q + (1 - p) * 3 * g - p * 2 * g
        k = int(np.argmax(metric))
        print(f"  {p:>11.1f} {g[k]:>17.2f} {metric[k]:>9.2f} {q[k] - 0.2 * g[k]:>9.2f}")
    print("""
观察：
  · 没有审核时，被评价的一方把全部预算拿去刷指标：指标最高，真实质量反而是负的。
  · 审核概率超过某个门槛（这里 p > 0.4，使得刷指标的期望收益 (1 − p)·3 − 2p 小于 1），刷指标就不划算了，
    全部预算回到真正的质量上。这是一个“全有或全无”的转变：门槛以下全刷，门槛以上全不刷。
  · RLHF 里的“被评价的一方”就是策略本身：它不需要“动机”，优化算法会自动找到奖励模型最便宜的漏洞（第 8 讲）。
    应对：让漏洞变贵 —— 奖励模型集成（第 6 讲）、定期更新奖励模型（第 10 讲）、人工抽查（这里的 p）、
    不公开评测集（防止针对性地优化）。""")


def main():
    rng = np.random.default_rng(1414)
    regressional(rng)
    extremal(rng)
    causal(rng)
    adversarial(rng)


if __name__ == "__main__":
    main()
