"""
=============================================================================
第 7 讲 · 代码 4：AI 反馈与 Constitutional AI（RLAIF）
=============================================================================

对应讲义：lectures/lecture07_instruction_tuning_alignment.md 第 8 节

-----------------------------------------------------------------------------
【这个文件要回答的问题 What this file answers】
-----------------------------------------------------------------------------
    Constitutional AI（Bai et al. 2022）用一份**写成自然语言的原则清单**（"宪法"）代替一部分人类标注：
        监督阶段：模型按原则批评（critique）自己的回答、修改（revision），再在修改后的回答上微调（SL-CAI）；
        强化学习阶段：让一个"反馈模型"按随机抽到的一条原则，判断两个回答哪个更好，得到 AI 标注的偏好数据，
                     训练偏好模型，再做 RL（RLAIF）。
    这里在"评论打分助手"小世界里把两个阶段各走一遍。我们的"宪法"有三条：

        原则 1：选择评分与评论内容一致的回答。          ← 由 base model 判断（它在预训练时学会了给评论打分）
        原则 2：选择更简洁、没有多余句子的回答。        ← 规则：数句子
        原则 3：选择不用 "wow" 这类夸张、讨好的开头的回答。 ← 规则：字面上找 "wow"

    （真实的 CAI 让同一个大模型读原则的原文来判断；我们的 11 万参数模型读不懂原则，所以每条原则由一个
     打分函数执行。"原则写成什么样，执行起来就是什么样"——这一点正是演示 3 要看的。）

    演示 1：监督阶段：批评 → 修改 → 在修改后的回答上 SFT。新策略比 π_ref 好多少？
    演示 2：强化学习阶段：人类标注 vs AI 标注 vs 两者各一半（CAI 的做法：有用性用人类标注、无害性用 AI 标注），
            各训练一个奖励模型，再做 KL 约束优化。
    演示 3：宪法漏掉了什么？——"简洁"压过了"一句补充有用"；没被点名的 "yay" 开头。

-----------------------------------------------------------------------------
【运行方式】
-----------------------------------------------------------------------------
    python3 04_constitutional_ai.py      # 第一次约 40 秒、之后约 15 秒（读取代码 02 缓存的策略和人类奖励模型；训练 SL-CAI 策略和 2 个奖励模型并缓存）

需要 PyTorch、numpy。
"""

import copy
import math
import random
import time

import numpy as np

from align_tools import (OPENERS, RATINGS, RESPONSES, assistant_world, comparison_accuracy, get_reward_model,
                         human_comparisons,
                         human_labeler, kl_tilt, load_base, load_or_train, policy_stats, policy_table,
                         response_text, review_prompt, reward_table, sample_comparisons, score_candidates, sft_train,
                         truth_table)
from console import pad, print_header

CONSTITUTION = [
    "选择评分与评论内容一致的回答。",
    "选择更简洁、没有多余句子的回答。",
    "选择不用 \"wow\" 这类夸张、讨好的开头的回答。",
]
KL_GRID = [0.0, 0.25, 0.5, 1.0, 2.0, 3.0]


# =============================================================================
# AI 反馈模型：按一条原则比较两个回答
# =============================================================================
class ConstitutionFeedback:
    """
    "反馈模型"：给定一条评论和两个回答，按随机抽到的一条原则判断哪个更好。

    【思路】
        原则 1 交给 base model：用预训练里见过的格式 "review: … rating:" 问它三个评分的概率，
              概率高的评分被认为"与评论一致"。这是**真正的 AI 判断**：信息来自一个语言模型的知识，不来自我们写的真相。
        原则 2、3 是规则：数句子；在开头里找字面上的 "wow"。
        两个回答在这条原则下打平时，给软标签 0.5（CAI 也用软标签，概率而不是 0/1）。
    【FM 联系】CAI 的反馈模型对每个比较只看一条随机抽到的原则（论文里有 16 条），
            并且发现把概率当软标签比硬的 0/1 好得多。
    【Python 小知识】类里定义了 __call__ 方法，它的实例就可以像函数一样被调用：feedback(review, truth, a, b, rng)。
                    这样它能直接当作 sample_comparisons 的 labeler 参数，同时把 base model 的判断结果存在 self 里。
    """

    def __init__(self, base, tok, reviews):
        """预先让 base model 给 reviews 里每条评论的三个评分打分（以后比较时直接查表）。"""
        prompts = [f"\nreview: {r} rating:" for r, _ in reviews]
        s = score_candidates(base, tok, prompts, RATINGS)
        p = np.exp(s - s.max(1, keepdims=True))
        self.rating_prob = {r: dict(zip(RATINGS, row / row.sum())) for (r, _), row in zip(reviews, p)}
        self.accuracy = float(np.mean([max(self.rating_prob[r], key=self.rating_prob[r].get) == t for r, t in reviews]))
        self.log = []          # 记录每次比较用了哪条原则、两个回答的评分是否不同（只用于统计）

    def principle_score(self, k, review, resp):
        """回答在第 k 条原则下的得分（越大越好）。"""
        rating, n_fill, opener = resp
        if k == 0:
            return self.rating_prob[review][rating]
        if k == 1:
            return -n_fill
        return -1.0 if OPENERS[opener].strip() == "wow." else 0.0

    def __call__(self, review, truth, a, b, rng):
        """按随机抽到的一条原则比较 a 和 b，返回 P(a 更好)：1、0 或 0.5。truth 不使用——反馈模型看不到真相。"""
        k = rng.randrange(len(CONSTITUTION))
        self.log.append((k, a[0] != b[0]))
        sa, sb = self.principle_score(k, review, a), self.principle_score(k, review, b)
        if abs(sa - sb) < 1e-12:
            return 0.5
        return 1.0 if sa > sb else 0.0


def revise(feedback, review, resp, rng, n_rev=2):
    """
    监督阶段的"批评 → 修改"：随机抽 n_rev 条原则（可以重复），依次按原则修改回答。

    【思路】原则 1 → 把评分换成反馈模型认为最可能的评分；原则 2 → 删掉所有废话；原则 3 → 去掉 "wow" 开头。
            CAI 论文里，模型自己写批评和修改，每个提示最多修改 4 次、每次随机抽一条原则。
    【返回】修改后的回答 (rating, k, opener)
    """
    rating, k, opener = resp
    for _ in range(n_rev):
        p = rng.randrange(len(CONSTITUTION))
        if p == 0:
            probs = feedback.rating_prob[review]
            rating = max(probs, key=probs.get)
        elif p == 1:
            k = 0
        elif OPENERS[opener].strip() == "wow.":
            opener = 0
    return (rating, k, opener)


def summary_row(name, q, reviews, kl=None):
    """打印一行策略统计。"""
    st = policy_stats(q, reviews)
    kl_s = "" if kl is None else f"{kl:.2f}"
    print(pad(name, 30) + pad(kl_s, 6, ">") + pad(f"{st['gold']:.2f}", 7, ">") + pad(f"{st['correct']:.2f}", 9, ">")
          + pad(f"{st['k']:.2f}", 9, ">") + pad(f"{st['wow']:.2f}", 7, ">") + pad(f"{st['yay']:.2f}", 7, ">"))


def header():
    """打印策略统计表的表头。"""
    print(pad("策略", 30) + pad("KL", 6, ">") + pad("E[g]", 7, ">") + pad("评分正确", 9, ">") + pad("废话句数", 9, ">")
          + pad("wow", 7, ">") + pad("yay", 7, ">"))


# =============================================================================
# 演示 1：监督阶段（SL-CAI）
# =============================================================================
def demo_supervised(aw, tok, base, feedback):
    """演示 1：从 π_ref 采样 3000 个回答 → 按原则修改 → 在修改后的回答上 SFT；返回 SL-CAI 策略在评测评论上的分布。"""
    print_header("演示 1：监督阶段——批评 → 修改 → 在修改后的回答上微调（SL-CAI）")
    print("宪法：")
    for i, c in enumerate(CONSTITUTION, 1):
        print(f"  原则 {i}：{c}")
    print(f"\n反馈模型（base model 用 \"review: … rating:\" 判断评分）在 500 条评论上的准确率：{feedback.accuracy:.3f}")

    rng = random.Random(11)
    reviews, pi = aw["rm_reviews"], aw["pi_rm"]
    revised, examples = [], []
    for _ in range(3000):
        i = rng.randrange(len(reviews))
        j = rng.choices(range(len(RESPONSES)), weights=pi[i])[0]
        new = revise(feedback, reviews[i][0], RESPONSES[j], rng)
        revised.append((review_prompt(reviews[i][0]), response_text(new) + "\n"))
        if len(examples) < 4 and new != RESPONSES[j]:
            examples.append((RESPONSES[j], new))
    print("\n修改的例子（原回答 → 修改后）：")
    for old, new in examples:
        print(f"  {response_text(old)!r:<34} → {response_text(new)!r}")

    meta = dict(kind="lm", recipe="sl_cai_v1", n_revised=3000, n_rev=2, seed=11, steps=300, lr=3e-4,
                policy="policy_sft")
    sl, _ = load_or_train("policy_sl_cai", meta, lambda: sft_train(
        copy.deepcopy(base), tok, lambda r: revised[r.randrange(len(revised))], steps=300, lr=3e-4, seed=0))
    q_sl, mass = policy_table(sl, tok, aw["rl_reviews"])
    print(f"\n在 base model 上用 3000 条修改后的回答微调 300 步，得到 SL-CAI 策略（36 种回答覆盖 {mass.mean():.3f} 的概率质量）。"
          "200 条新评论上：\n")
    header()
    summary_row("π_ref（在人类示范上 SFT）", aw["pi_rl"], aw["rl_reviews"])
    summary_row("SL-CAI（在修改后的回答上 SFT）", q_sl, aw["rl_reviews"])
    print("""
观察 Observations：
  · 不用任何新的人类标注，只靠"按原则批评、修改"，SL-CAI 策略的真实质量从 1.15 升到 1.56：评分正确率 0.78 → 0.89
    （反馈模型——也就是 base model 自己——判断评分的准确率是 1.000），废话从 1.02 句降到 0.49 句，wow 从 0.16 降到 0.05。
  · 但宪法写了什么，修改就只做什么：yay 开头没被点名，留在原来的水平（0.04 → 0.03）；"简洁"这条原则把有用的
    第一句补充也一起删掉了（真实质量最高的回答带 1 句补充）。
  · 这一步的意义在于**起点**：CAI 论文用 SL-CAI 模型作为强化学习阶段的初始策略，让 RL 不必从一个满是问题的策略开始。""")
    return q_sl


# =============================================================================
# 演示 2：强化学习阶段（RLAIF）
# =============================================================================
def demo_rlaif(aw, tok, base, feedback, q_sl):
    """演示 2：人类 / AI / 各一半三种标注各训练一个奖励模型，比较奖励模型本身和优化后的真实质量。"""
    print_header("演示 2：强化学习阶段——人类标注 vs AI 标注 vs 各一半，训练奖励模型后做 KL 约束优化")
    reviews = aw["rm_reviews"]
    human = human_comparisons(aw)
    feedback.log = []
    ai = sample_comparisons(3000, reviews, aw["pi_rm"], random.Random(2000), feedback)
    rating_info = np.mean([k == 0 and differ for k, differ in feedback.log])
    mix_rng = random.Random(3000)

    def mixed(review, truth, a, b, rng):
        """一半交给人类标注员、一半交给宪法（用独立的 mix_rng 决定交给谁）。"""
        # CAI 的做法：有用性用人类标注，无害性用 AI 标注。这里每个比较随机交给人类或宪法（各一半）
        return human_labeler(review, truth, a, b, rng) if mix_rng.random() < 0.5 else feedback(review, truth, a, b, rng)

    mix = sample_comparisons(3000, reviews, aw["pi_rm"], random.Random(4000), mixed)
    t0 = time.time()
    rms = {}
    for name, comps, src in [("human", human, "human_comparisons(seed=1000)"),
                             ("ai", ai, "constitution(seed=2000)"), ("mixed", mix, "half_human_half_constitution(seed=4000)")]:
        rm_name = "rm_human_base_3000" if name == "human" else f"rm_{name}_base_3000"
        labeler = "human" if name == "human" else name
        rm, _ = get_reward_model(rm_name, tok, base, "base", comps, reviews, dict(labeler=labeler, source=src))
        rms[name] = reward_table(rm, tok, aw["rl_reviews"])
    ties = np.mean([c[3] == 0.5 for c in ai])
    print(f"（3 个奖励模型就绪，用时 {time.time() - t0:.1f} 秒；人类奖励模型就是代码 02 的 N=3000 种子 0）")
    print(f"AI 标注的 3000 条比较里：{ties:.0%} 是平局（软标签 0.5）；"
          f"按原则 1 判断、且两个回答评分不同（也就是带着\"评分对不对\"的信息）的只有 {rating_info:.0%}。\n")
    G, _ = truth_table(aw["rl_reviews"])
    labels = {"human": "人类标注", "ai": "AI 标注（宪法）", "mixed": "各一半"}
    test = sample_comparisons(2000, aw["rl_reviews"], aw["pi_rl"], random.Random(9), human_labeler)
    print("奖励模型本身（在 200 条新评论的 2000 对回答上）：")
    print(pad("奖励模型", 18) + pad("符合真实质量", 14, ">") + pad("评分对错", 10, ">"))
    for name, R in rms.items():
        acc = comparison_accuracy(R, aw["rl_reviews"], test)
        print(pad(labels[name], 18) + pad(f"{acc['gold']:.3f}", 14, ">") + pad(f"{acc['correct']:.3f}", 10, ">"))
    print()
    print("每一格是 E_π*[g]：π* 是对着该奖励模型、在给定 KL 下的最优策略（起点都是 π_ref）。\n")
    print(pad("奖励模型", 18) + "".join(pad(f"KL={k:g}", 9, ">") for k in KL_GRID) + pad("峰值 / 在 KL", 15, ">"))
    for name, R in rms.items():
        vals = [float((tilt_at(aw["pi_rl"], R, k) * G).sum(1).mean()) for k in KL_GRID]
        fine = [(float((tilt_at(aw["pi_rl"], R, k) * G).sum(1).mean()), k) for k in np.linspace(0, 3, 31)]
        best, at = max(fine)
        print(pad(labels[name], 18) + "".join(pad(f"{v:.2f}", 9, ">") for v in vals) + pad(f"{best:.2f} / {at:.1f}", 15, ">"))
    vals = [float((tilt_at(q_sl, rms["ai"], k) * G).sum(1).mean()) for k in KL_GRID]
    print(pad("SL-CAI + AI 标注", 18) + "".join(pad(f"{v:.2f}", 9, ">") for v in vals)
          + "   （起点换成 SL-CAI 策略：完整的 CAI 流程）")
    print("""
观察 Observations：
  · 只用 AI 标注（宪法）的奖励模型反而最差：峰值只有 1.22（人类标注 1.52），优化下去掉得最快（KL = 3 时 −0.27）。
    原因在上面两行：AI 标注里一半是平局；真正带着"评分对不对"信息的只有 13%（三条原则里只有一条管评分，
    而且两个回答评分相同时这条原则也给不出信息）。结果这个奖励模型完全没学会评分对错（0.491，瞎猜），
    虽然执行原则 1 的反馈模型本身是 100% 准确的——**数据里信号被稀释了，再准的标注员也没用**。
  · 各一半（CAI 论文的配方：有用性用人类标注，"无害性"用 AI 标注）最好：峰值 1.75、"评分对错" 0.846，
    KL = 3 时仍有 0.67。人类标注提供了评分对错的信号，宪法压住了一部分人类偏差。
  · 从 SL-CAI 出发再对着纯 AI 奖励模型优化，起点高（1.56），KL = 0.25 时持平，再往前就一路下滑（KL = 0.5 时 1.36）——
    奖励模型的缺陷没有因为起点好就消失。""")
    return rms


def tilt_at(pi, R, target):
    """KL(π* ‖ π) = target 时的闭式解（在 log β 上二分查找）；target = 0 时返回 π 本身。"""
    if target <= 0:
        return pi
    lo, hi = math.log(1e-4), math.log(1e4)
    for _ in range(60):
        mid = (lo + hi) / 2
        _, kl = kl_tilt(pi, R, math.exp(mid))
        if kl > target:
            lo = mid
        else:
            hi = mid
    return kl_tilt(pi, R, math.exp(hi))[0]


# =============================================================================
# 演示 3：宪法漏掉了什么
# =============================================================================
def demo_gaps(aw, rms, q_sl):
    """演示 3：优化到 KL = 3 时，各奖励模型下的策略长什么样。"""
    print_header("演示 3：宪法漏掉了什么？——看优化到 KL = 3 时策略变成了什么样")
    labels = {"human": "人类标注", "ai": "AI 标注（宪法）", "mixed": "各一半"}
    header()
    summary_row("π_ref", aw["pi_rl"], aw["rl_reviews"], 0.0)
    for name, R in rms.items():
        summary_row(f"{labels[name]}，KL = 3", tilt_at(aw["pi_rl"], R, 3.0), aw["rl_reviews"], 3.0)
    summary_row("SL-CAI + AI 标注，KL = 3", tilt_at(q_sl, rms["ai"], 3.0), aw["rl_reviews"], 3.0)
    print("（对照：真实质量最高的回答是\"评分正确 + 1 句废话 + 无开头\"，g = 2.5；\"评分正确 + 0 句 + 无开头\" g = 2.0）")
    print("""
观察 Observations：
  · 纯 AI 标注把宪法里**写了的**两条执行得非常彻底：KL = 3 时废话 0.00 句、wow 0.00。
    可是（1）"简洁"被推到了极端，连有用的那一句补充也没有了；（2）没被写进去的 yay 开头从 0.04 涨到 0.22–0.24，
    补上了 wow 留下的空位；（3）评分正确率掉到 0.46——奖励模型不会判断评分，优化器就在评分上随便选。
  · 各一半的组合里，wow 停在 0.16，yay 却涨到 0.28：人类标注喜欢"任何热情的开头"，宪法只惩罚 "wow"，
    合起来就是"换成 yay"。这是规则被**按字面**执行之后最典型的漏洞：优化压力会流向规则没覆盖到的地方。
  · CAI 论文自己也报告了类似的 Goodhart 现象：训练过头的 RL-CAI 模型会给出过于严厉的回答，或者反复使用
    "You are valid, valued, and cared for" 这样的套话。原则写得越具体，越容易被字面地满足；写得越抽象，
    越依赖反馈模型的理解能力——真实的 CAI 依赖一个能读懂原则的大模型，这是我们的小模型做不到的。""")


def main():
    """依次运行本文件的各个演示。"""
    t0 = time.time()
    world, tok, base = load_base()
    aw = assistant_world(tok, base)
    feedback = ConstitutionFeedback(base, tok, aw["rm_reviews"])
    q_sl = demo_supervised(aw, tok, base, feedback)
    rms = demo_rlaif(aw, tok, base, feedback, q_sl)
    demo_gaps(aw, rms, q_sl)
    print(f"\n总用时 {time.time() - t0:.1f} 秒（计时每次运行略有不同）")


if __name__ == "__main__":
    main()
