"""
=============================================================================
第 13 讲 · 代码 1：怎样给转写打分 —— 编辑距离的动态规划、CER / WER / 归一化相似度、规范化、按页平均
=============================================================================

对应讲义：lectures/lecture13_churro.md 第 2 节

-----------------------------------------------------------------------------
【这个文件要解决什么问题？】
-----------------------------------------------------------------------------
"模型 A 的转写比模型 B 好"—— 这句话要落到一个数字上。CHURRO 论文用**归一化的 Levenshtein 相似度**
给每一页打分，再按页平均。这个文件把这个数字拆开来看：

    演示 1：编辑距离的动态规划表 —— 每一格是怎样算出来的
    演示 2：同一行标准转写，七种不同的模型输出 —— CER、WER、相似度各说明了什么；为什么相似度要除以较长的那个
    演示 3：规范化 —— 看起来一样的字符，编码可能不同；"按原样转写"和"现代化转写"
    演示 4：按页平均 vs 把所有页拼在一起算 —— 长页面会不会"淹没"短页面

-----------------------------------------------------------------------------
【运行方式】
-----------------------------------------------------------------------------
    python 01_measuring_transcription.py        # 不到 1 秒
"""

from ocr_metrics import cer, levenshtein, levenshtein_table, normalize, pad, similarity, wer

# 一段虚构的 18 世纪风格的英文证明信（ſ 是"长 s"，当时的印刷和书写里常见）
GOLD = ("The Bearer hereof, Mr. Thomas Whitfield, is a Perſon of good Character, "
        "who hath ſerved in this Pariſh theſe ſeven Years paſt.")


def print_header(title):
    """打印一个带分隔线的标题。"""
    print()
    print("=" * 78)
    print(f"  {title}")
    print("=" * 78)


def demo_table():
    """
    演示 1：模型把"receive"读成了"recieve"，编辑距离是多少？

    【观察】表格的右下角就是答案：2。交换两个相邻的字母，在 Levenshtein 距离里算两次替换。
        每一格只看左边、上边、左上三格 —— 这就是动态规划：大问题的答案由小问题的答案拼出来。
    """
    print_header("演示 1：编辑距离的动态规划表（模型输出 recieve，标准答案 receive）")
    a, b = "recieve", "receive"
    d, ops = levenshtein_table(a, b)
    print(" " * 9 + "".join(f"   {ch}" for ch in b))
    for i in range(len(a) + 1):
        label = " " if i == 0 else a[i - 1]
        print(f"   {label}  " + " ".join(f"{d[i][j]:3d}" for j in range(len(b) + 1)))
    print(f"\n  编辑距离 = 右下角 = {d[len(a)][len(b)]}")
    print("  一条最优的编辑路径：" + "，".join(f"{op}{'(' + x + '→' + y + ')' if op == '替换' else ''}"
                                         for op, x, y in ops))


def demo_metrics():
    """
    演示 2：七种模型输出，三个指标。

    【观察】
        · 字符错误（ſ 读成 f、o 读成 ø）对 CER 影响小，对 WER 影响大：一个字母错了，整个词就错了；
        · 把 ſ 都换成 s 的"现代化"转写，按"原样转写"的标准是错的（第 3 节）；
        · 只转写了一半：CER 和 1 − 相似度差不多；
        · **编了一大段**（幻觉）或**重复循环**（退化）：输出比答案长得多，CER 远大于 1，
          相似度最低也只到 0 —— 这就是 CHURRO 论文除以较长那个字符串的原因：每一页的分数都在 0~1 之间，
          一页灾难性的输出不会把整体平均拉到负数；
        · 空输出：CER = 1，相似度 = 0。
    """
    print_header("演示 2：同一行标准转写，七种模型输出")
    half = GOLD[:len(GOLD) // 2]
    preds = [
        ("完全正确", GOLD),
        ("几个字符看错（ſ→f、o→ø）", GOLD.replace("Perſon", "Perfon").replace("good", "gøod")),
        ("把 ſ 都写成 s（现代化）", GOLD.replace("ſ", "s")),
        ("只转写了前一半", half),
        ("编了一大段", GOLD + " He is moreover well skilled in the keeping of Accounts, and is recommended "
                        "to all Persons whom it may concern as a faithful and honest Servant."),
        ("重复循环（退化）", half + " Pariſh theſe" * 12),
        ("什么也没输出", ""),
    ]
    print(f"  标准答案（{len(GOLD)} 个字符、{len(GOLD.split())} 个词）：{GOLD}\n")
    print("  模型输出                         编辑距离     CER     WER   相似度")
    for label, p in preds:
        print(f"  {pad(label, 32)}{levenshtein(p, GOLD):6d}{cer(p, GOLD):9.2f}{wer(p, GOLD):8.2f}{similarity(p, GOLD):8.2f}")


def demo_normalization():
    """
    演示 3：规范化。

    【观察】
        · "é"可以存成一个字符（U+00E9），也可以存成"e" + 组合的重音符号（U+0301）。屏幕上一模一样，
          不规范化的话编辑距离是 2（一个替换、一个多余的字符）；NFC 规范化之后是 0；
        · 连字"ﬆ"是一个字符，标准答案里写成"st"两个字符：拆开之后才能公平比较；
        · 长 s：CHURRO-DS 统一采用**按原样转写**（diplomatic）—— 看到什么写什么，不替读者"翻译"成现代拼写。
          所以评分时不能把 ſ 规范化成 s，否则"读错了"和"读对了"会得到同样的分数。
    """
    print_header("演示 3：规范化")
    composed, decomposed = "café", "café"
    print(f"  '{composed}'（{len(composed)} 个码位） vs '{decomposed}'（{len(decomposed)} 个码位）：")
    print(f"    不规范化：编辑距离 {levenshtein(composed, decomposed)}；NFC 之后："
          f"{levenshtein(normalize(composed), normalize(decomposed))}")
    lig = "the ﬆreet of the Pariſh"
    plain = "the street of the Pariſh"
    print(f"  连字：'{lig}' vs '{plain}'")
    print(f"    不拆开：编辑距离 {levenshtein(lig, plain)}；拆开连字之后：{levenshtein(normalize(lig), normalize(plain))}")
    modern = GOLD.replace("ſ", "s")
    print("  长 s：模型输出了现代拼写（把 ſ 都写成了 s）")
    print(f"    按原样转写的标准（不替换 ſ）：相似度 {similarity(normalize(modern), normalize(GOLD)):.3f}")
    print(f"    如果评分时把 ſ 规范化成 s：      相似度 "
          f"{similarity(normalize(modern, long_s=True), normalize(GOLD, long_s=True)):.3f}"
          "（看不出模型改了原文）")
    accented = "Ḥalab wa-Dimashq"
    print(f"  去掉变音符号（论文对阿拉伯文规范化元音符号和 hamza，这里用拉丁转写示意）："
          f"'{accented}' → '{normalize(accented, strip_marks=True)}'")


def demo_page_average():
    """
    演示 4：三页长度差很多的转写：按页算相似度再平均，还是把所有页拼起来一起算？

    【观察】拼起来算时，长页面的权重大得多 —— 一页 2,000 字的报纸几乎决定了总分，
        200 字的短信件几乎不起作用。CHURRO 论文**按页**算相似度再平均（印刷和手写分开），
        每一页同样重要；又在每个语言上分别报告，避免样本多的语言淹没样本少的语言。
    """
    print_header("演示 4：按页平均 vs 拼在一起算")
    long_gold = (GOLD + " ") * 15
    pages = [("短信件", GOLD, GOLD[:60]),                                  # 只读出了开头 —— 很差
             ("中等的一页", (GOLD + " ") * 3, ((GOLD + " ") * 3).replace("ſ", "f")),
             ("很长的报纸", long_gold, long_gold.replace("Whitfield", "Whitfeld"))]   # 几乎完美
    sims = []
    for name, g, p in pages:
        s = similarity(p, g)
        sims.append(s)
        print(f"  {pad(name, 12)}{len(g):6d} 个字符   相似度 {s:.3f}")
    total_d = sum(levenshtein(p, g) for _, g, p in pages)
    total_n = sum(max(len(p), len(g)) for _, g, p in pages)
    print(f"  按页平均：{sum(sims) / len(sims):.3f}    拼在一起算：{1 - total_d / total_n:.3f}")


def main():
    demo_table()
    demo_metrics()
    demo_normalization()
    demo_page_average()


if __name__ == "__main__":
    main()
