"""
=============================================================================
第 1 讲 · 代码 1：从"写规则"到"数数据"——亲手体验 NLP 的前两个时代
=============================================================================

对应讲义：lectures/lecture01_nlp_history_word_vectors.md 第 3 节（NLP 简史），
         以及第 4.2 节（为什么需要"词与词之间的相似度"）

-----------------------------------------------------------------------------
【这个文件要解决什么问题？】
-----------------------------------------------------------------------------
讲义第 3 节讲了 NLP 七十年的历史，其中有两次大的"范式转变"：
    规则时代（约 1950s–1980s）：语言学家和程序员**手写规则**告诉计算机怎么处理语言。
    统计时代（约 1990s–2012） ：人类只负责**标注数据**，计算机从数据里**数出**规律。
光听历史很难体会"为什么规则走不通""统计方法又卡在哪里"。这个文件让你亲手体验：

    第 1 部分：写一个迷你版 ELIZA —— 1966 年的"心理治疗师"聊天程序，规则时代的代表作。
    第 2 部分：给 ELIZA 加一条"识别用户难过"的规则，然后用各种说法去测试它，
              看规则有多**脆弱**（换个说法就失灵、遇到否定就出错）。
    第 3 部分：换成统计方法 —— 朴素贝叶斯（Naive Bayes）分类器，从标注好的例句里自动学习。
              它比规则泛化得好，但遇到训练时**没见过的词**就束手无策。
    第 4 部分：为什么没见过的词就没办法？因为在它眼里每个词都是一个孤立的符号，
              "sad" 和 "miserable" 毫无关系 —— 这正是本讲后半部分（词向量）要解决的问题。

-----------------------------------------------------------------------------
【运行方式】
-----------------------------------------------------------------------------
    python 01_from_rules_to_statistics.py          # 运行全部演示
    python 01_from_rules_to_statistics.py --chat   # 和迷你 ELIZA 聊天（输入 quit 退出）

只用到 Python 标准库。
"""

# 【Python 小知识】
#   re  ：正则表达式（regular expression）模块。正则是一种"描述字符串模式"的小语言，
#         比如 r"i am (.*)" 能匹配 "i am sad"，并把 "sad" 抓出来。规则时代的系统大量使用这类模式匹配。
#         字符串前面的 r 表示"原始字符串"：反斜杠 \ 不会被 Python 当成转义符，写正则时很方便。
#   math：数学函数，这里用 math.log（自然对数）。
#   sys ：sys.argv 是命令行参数列表，sys.argv[0] 是脚本名，后面是用户输入的参数（如 "--chat"）。
import math
import re
import sys
from collections import Counter


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


# =============================================================================
# 第 1 部分：迷你 ELIZA —— 规则时代的聊天程序
# =============================================================================
# ELIZA 的工作原理（Weizenbaum, 1966）非常简单：
#   1. 在用户的话里找"关键词模式"（比如 "i need ..."）；
#   2. 把模式抓到的片段做"人称转换"（my → your，i → you）；
#   3. 套进一个回复模板（"Why do you need ...?"）。
# 它完全不"理解"语言，只是在做字符串替换。但 1966 年的很多用户真的以为它懂自己，
# Weizenbaum 本人对此非常震惊，后来写书批评人们对计算机的过度信任。

# 规则表：每条规则 = (正则模式, 回复模板列表)。规则按顺序尝试，第一条匹配上的生效。
# 模板里的 {0} 会被替换成正则第一个括号 (.*) 抓到的内容（经过人称转换）。
ELIZA_RULES = [
    (r"\bi need (.*)", ["Why do you need {0}?", "Would it really help you to get {0}?"]),
    (r"\bi am (.*)", ["How long have you been {0}?", "Why do you tell me you are {0}?"]),
    (r"\bi feel (.*)", ["Tell me more about feeling {0}.", "Do you often feel {0}?"]),
    (r"\bbecause (.*)", ["Is that the real reason?", "What other reasons come to mind?"]),
    (r"\b(?:mother|father|mom|dad)\b(.*)", ["Tell me more about your family.",
                                             "How do you get along with your family?"]),
    (r"\byou are (.*)", ["What makes you think I am {0}?", "Does it please you to believe I am {0}?"]),
    (r"(.*)\?$", ["Why do you ask that?", "What do you think?"]),
]
# 没有任何规则匹配时的"万能回复"——这是 ELIZA 最常露馅的地方。
ELIZA_FALLBACKS = ["Please tell me more.", "I see. Go on.", "How does that make you feel?"]

# 人称转换表：用户说 "my job"，ELIZA 要回 "your job"。
REFLECTIONS = {
    "i": "you", "me": "you", "my": "your", "am": "are", "mine": "yours",
    "you": "I", "your": "my", "yours": "mine", "myself": "yourself",
}


def reflect(fragment):
    """
    对一段文字做人称转换：i → you、my → your、you → I ……

    【思路】
        按空格切成词，逐个查 REFLECTIONS 表，查得到就替换，查不到就保留原词。

    【参数】
        fragment : str，例如 "worried about my exam"

    【返回】
        str，例如 "worried about your exam"

    【边界】
        - 只按空格切分，所以 "exam." 这种带标点的词不会被转换（规则系统的典型粗糙之处）。
        - "you" 既可能是主语也可能是宾语（"you → I" 还是 "you → me"？），
          一张简单的表无法区分 —— 想区分就得写更多规则，这就是规则越写越多的原因。

    【Python 小知识】
        REFLECTIONS.get(w, w)：字典里有 w 就返回对应的值，没有就返回默认值 w（即原词不变）。
        " ".join(列表)：用空格把列表里的字符串连成一个字符串。
    """
    return " ".join(REFLECTIONS.get(w, w) for w in fragment.lower().split())


class MiniEliza:
    """
    迷你 ELIZA 聊天机器人。

    【用法】
        bot = MiniEliza()
        reply, rule_id = bot.respond("I am worried about my exam")
        # reply = "How long have you been worried about your exam?"，rule_id = 1

    【边界】
        - 回复是**确定的**：同一条规则被多次触发时，按顺序轮流使用它的回复模板
          （原版 ELIZA 也是这样轮换的），所以每次运行本文件的输出都一样。

    【NLP 联系】
        人们会把"理解"投射到其实毫无理解的程序上，这被称为"ELIZA 效应"。
        今天评估聊天机器人时，这依然是一个需要警惕的陷阱：流畅 ≠ 理解。
    """

    def __init__(self, rules=ELIZA_RULES, fallbacks=ELIZA_FALLBACKS):
        """
        【参数】
            rules     : list[(正则字符串, 回复模板列表)]
            fallbacks : list[str]，没有规则匹配时轮流使用的回复

        【Python 小知识】
            re.compile(模式, re.IGNORECASE)：把正则"编译"成对象，反复使用时更快；
            IGNORECASE 表示不区分大小写（"I am" 和 "i am" 都能匹配）。
        """
        self.rules = [(re.compile(p, re.IGNORECASE), templates) for p, templates in rules]
        self.fallbacks = fallbacks
        self.usage = Counter()      # 记录每条规则被用了几次，用来轮换模板

    def respond(self, text):
        """
        对用户的一句话给出回复。

        【参数】
            text : str，用户输入。

        【返回】
            (reply, rule_id)
                reply   : str，回复内容
                rule_id : int 或 None。匹配上的规则编号（从 0 开始）；None 表示用了万能回复。

        【边界】
            - 空字符串或纯空格：没有规则能匹配，返回万能回复。
            - 句末标点（. !）会被去掉，避免出现 "Why do you need help.?" 这样的回复。

        【Python 小知识】
            pattern.search(text)：在 text 中**任意位置**查找模式，找到返回 Match 对象，否则返回 None。
            match.groups()：返回所有括号抓到的内容组成的元组，例如 ("worried about my exam",)。
            t.format(*args)：把元组展开成多个参数填进模板的 {0}、{1}……
        """
        cleaned = text.strip().rstrip(".!")
        for rule_id, (pattern, templates) in enumerate(self.rules):
            match = pattern.search(cleaned)
            if match:
                groups = [reflect(g) for g in match.groups()]
                template = templates[self.usage[rule_id] % len(templates)]
                self.usage[rule_id] += 1
                return template.format(*groups), rule_id
        reply = self.fallbacks[self.usage["fallback"] % len(self.fallbacks)]
        self.usage["fallback"] += 1
        return reply, None


def demo_eliza_conversation():
    """演示 1：一段预先写好的对话，看 ELIZA 如何"假装理解"。"""
    print_header("演示 1：迷你 ELIZA —— 只靠模式匹配和人称转换的'心理治疗师'")
    bot = MiniEliza()
    script = [
        "I am worried about my exam",
        "I need some help with my studies",
        "My mother says I should work harder",
        "Because she thinks I am lazy",
        "You are not very helpful",
        "Do you understand me?",
        "The weather is nice today",
        "I saw a man on the hill with a telescope",
    ]
    for line in script:
        reply, rule_id = bot.respond(line)
        tag = f"规则 {rule_id}" if rule_id is not None else "万能回复"
        print(f"  用户 : {line}")
        print(f"  ELIZA: {reply:<52s} [{tag}]")
    print("""
  观察：
    - 前几句看起来像是"听懂了"，其实只是把 my 换成 your 再套模板。
    - "The weather is nice today" 没有任何规则匹配，只能用万能回复敷衍。
    - 最后一句有歧义（是"我拿着望远镜看到人"还是"人拿着望远镜"？），
      ELIZA 完全不知道歧义的存在 —— 它根本没有"理解"这一层。""")


# =============================================================================
# 第 2 部分：规则有多脆弱？——给 ELIZA 加一条"识别难过"的规则
# =============================================================================
# 假设产品经理要求：用户表达难过时，机器人要识别出来并安慰他。
# 规则工程师第一版写了两条最直接的规则：
SAD_RULES_V1 = [r"\bi am sad\b", r"\bi feel sad\b"]

# 第二版：看到第一版漏掉很多句子后，工程师补了 3 条规则：
#   缩写和程度副词（I'm so sad）、两个习惯说法（feel down / feeling blue），
#   以及一条"宽松"的关键词规则 —— 只要句子里出现 sad / unhappy / depressed 就算难过。
SAD_RULES_V2 = SAD_RULES_V1 + [
    r"\bi'?m (?:so |very |really )?sad\b",
    r"\bfeel(?:ing)? (?:blue|down)\b",
    r"\b(?:sad|unhappy|depressed)\b",
]

# 测试用例：(句子, 是否真的在表达"我难过")。这些都是人类一眼就能判断的句子。
SAD_TEST_CASES = [
    ("I am sad", True),
    ("I'm sad", True),
    ("I feel sad today", True),
    ("I am so sad", True),
    ("I feel down", True),
    ("I'm feeling blue", True),
    ("Sad is exactly how I feel", True),
    ("My heart is heavy and I keep crying", True),
    ("I am miserable", True),
    ("Nothing makes me happy anymore", True),
    ("I am not sad at all", False),          # 否定！
    ("I am sad that you won't come? No, I'm kidding, I am thrilled", False),   # 反讽
    ("The movie was sad but I loved it", False),
    ("I am happy", False),
]


def rule_based_detect(text, rules):
    """
    用一组正则规则判断一句话是否在表达难过：任意一条规则匹配就判为"难过"。

    【参数】
        text  : str
        rules : list[str]，正则模式字符串

    【返回】
        bool

    【边界】
        - 规则只看字面模式，看不懂否定（"not sad"）、反讽、比喻（"heart is heavy"）。
    """
    return any(re.search(p, text, re.IGNORECASE) for p in rules)


def evaluate_detector(detect_fn, cases):
    """
    在测试用例上评估一个"难过检测器"，打印每句的结果并返回统计数字。

    【参数】
        detect_fn : 函数，输入句子返回 bool
        cases     : list[(句子, 真实标签 bool)]

    【返回】
        (accuracy, recall, false_positives)
            accuracy        : 判断正确的比例
            recall          : 真正难过的句子里，被识别出来的比例（召回率）
            false_positives : 不难过却被判为难过的句子数（误报）
    """
    correct = hits = positives = fp = 0
    for text, truth in cases:
        pred = detect_fn(text)
        correct += pred == truth
        positives += truth
        hits += pred and truth
        fp += pred and not truth
        mark = "✓" if pred == truth else "✗"
        print(f"    {mark} 预测={'难过' if pred else '不是':<3s} 真实={'难过' if truth else '不是':<3s}  {text}")
    return correct / len(cases), hits / positives, fp


def demo_rule_brittleness():
    """演示 2：规则的脆弱性 —— 换个说法就失灵，补规则永远补不完。"""
    print_header("演示 2：规则有多脆弱？")
    for name, rules in [("第一版规则（2 条）", SAD_RULES_V1), ("第二版规则（5 条）", SAD_RULES_V2)]:
        print(f"\n  {name}：")
        acc, recall, fp = evaluate_detector(lambda t: rule_based_detect(t, rules), SAD_TEST_CASES)
        print(f"    → 准确率 {acc:.0%}，召回率（难过的句子被识别出的比例）{recall:.0%}，误报 {fp} 句")
    print("""
  观察：
    - 第一版只认得字面上的 "i am sad / i feel sad"，换成缩写 "I'm" 就失灵；
      而反讽句里恰好有 "I am sad" 这几个字，又被误报了。
    - 第二版加了一条宽松的关键词规则，召回率上去了，却带来了 3 个误报：
      "I am not sad at all"（规则不懂否定）、反讽句、"The movie was sad"（难过的是电影，不是用户）。
    - "My heart is heavy""Nothing makes me happy anymore""I am miserable" 依然识别不出
      （规则不懂比喻、间接表达，也不知道 miserable 是 sad 的近义词）。
    - 规则写得严 → 漏掉很多说法；写得宽 → 误报一大堆。每补一条规则都可能引入新的错误，
      而人类的说法是无穷无尽的。
      这就是规则时代的根本困境：**覆盖不全 + 规则之间互相冲突 + 维护成本爆炸**。""")


# =============================================================================
# 第 3 部分：统计方法 —— 朴素贝叶斯分类器
# =============================================================================
# 统计时代的思路：不再手写规则，而是**收集标注好的例句**，让程序自己数出
# "哪些词经常出现在难过的句子里，哪些词经常出现在开心的句子里"。
# 训练数据：(句子, 标签)。注意这里一共只有 24 句，真实系统会用成千上万句。
TRAIN_DATA = [
    ("i am so sad today", "sad"), ("i feel sad and lonely", "sad"),
    ("i feel down and tired", "sad"), ("i keep crying at night", "sad"),
    ("my heart is broken", "sad"), ("i lost my best friend", "sad"),
    ("everything feels hopeless", "sad"), ("i am unhappy with my life", "sad"),
    ("tears in my eyes again", "sad"), ("nothing makes me smile anymore", "sad"),
    ("i feel empty inside", "sad"), ("i am depressed and alone", "sad"),
    ("i am so happy today", "happy"), ("i feel great and excited", "happy"),
    ("what a wonderful day", "happy"), ("i love my new job", "happy"),
    ("we won the game", "happy"), ("i am glad you came", "happy"),
    ("this is the best day ever", "happy"), ("i feel joyful and free", "happy"),
    ("my heart is full of joy", "happy"), ("i smile all the time", "happy"),
    ("i am thrilled about the trip", "happy"), ("life is good", "happy"),
]


def tokenize(text):
    """
    最简单的分词：转小写，只保留字母和撇号组成的"词"。

    【参数】
        text : str
    【返回】
        list[str]，例如 "I'm so SAD!" → ["i'm", "so", "sad"]

    【Python 小知识】
        re.findall(模式, 字符串)：返回所有匹配的子串组成的列表。
        [a-z']+ 表示"由小写字母或撇号组成的、至少一个字符长的串"。
    """
    return re.findall(r"[a-z']+", text.lower())


class NaiveBayesClassifier:
    """
    朴素贝叶斯文本分类器（多项式模型 + 加一平滑）。

    【思路】（讲义第 3.3 节有直觉解释）
        想知道一句话属于哪一类，就比较 P(类别 | 句子) 的大小。根据贝叶斯公式：
            P(类别 | 句子) ∝ P(类别) × P(句子 | 类别)
        "朴素"的假设：句子里的词**相互独立**，于是
            P(句子 | 类别) = P(词1 | 类别) × P(词2 | 类别) × ...
        每个概率都可以直接从训练数据里**数**出来：
            P(类别)        = 该类句子数 / 总句子数
            P(词 | 类别)   = (该词在该类中出现的次数 + 1) / (该类的总词数 + 词表大小)
        "+1" 叫加一平滑（Laplace smoothing），防止某个词在某类中出现 0 次导致整个乘积变成 0。
        连乘很多个小于 1 的数会变得极小（下溢），所以实际计算时取对数，把乘法变成加法。

    【边界】
        - 训练时**从没见过的词**直接忽略（它对两类的证据一样，无法提供任何信息）。
          如果一句话里所有词都没见过，模型只能退回到先验 P(类别) —— 等于瞎猜。
        - "朴素"的独立性假设显然不对（"not" 和 "happy" 一起出现时意思完全反转），
          但这个模型在很多文本分类任务上出奇地好用，统计时代被广泛使用。

    【NLP 联系】
        早期的垃圾邮件过滤器大多就是朴素贝叶斯。今天它仍然常被用作文本分类的"基线"：
        一个新方法如果连朴素贝叶斯都打不过，就要好好检查了（第 8 讲）。
    """

    def fit(self, data):
        """
        从标注数据中数出所有需要的统计量。

        【参数】
            data : list[(句子 str, 标签 str)]

        【返回】
            self（这样可以链式调用：clf = NaiveBayesClassifier().fit(data)）
        """
        self.label_counts = Counter(label for _, label in data)
        self.word_counts = {label: Counter() for label in self.label_counts}
        for text, label in data:
            self.word_counts[label].update(tokenize(text))
        self.vocab = set(w for counts in self.word_counts.values() for w in counts)
        self.total_words = {label: sum(c.values()) for label, c in self.word_counts.items()}
        return self

    def log_scores(self, text):
        """
        计算每个类别的对数得分 log P(类别) + Σ log P(词 | 类别)，只累加见过的词。

        【参数】
            text : str

        【返回】
            (scores, known, unknown)
                scores  : dict[标签, float]，对数得分（越大越可能）
                known   : list[str]，训练时见过的词
                unknown : list[str]，训练时没见过、被忽略的词
        """
        words = tokenize(text)
        known = [w for w in words if w in self.vocab]
        unknown = [w for w in words if w not in self.vocab]
        n_docs = sum(self.label_counts.values())
        scores = {}
        for label in self.label_counts:
            s = math.log(self.label_counts[label] / n_docs)
            for w in known:
                s += math.log((self.word_counts[label][w] + 1) / (self.total_words[label] + len(self.vocab)))
            scores[label] = s
        return scores, known, unknown

    def predict(self, text):
        """返回得分最高的类别（str）。两类得分完全相同时，返回字母顺序在前的类别。"""
        scores, _, _ = self.log_scores(text)
        return max(sorted(scores), key=lambda label: scores[label])


def demo_naive_bayes():
    """演示 3：统计方法能泛化到没见过的句子，但对没见过的词无能为力。"""
    print_header("演示 3：统计方法 —— 从 24 句标注数据里'数'出规律")
    clf = NaiveBayesClassifier().fit(TRAIN_DATA)
    print(f"\n  训练数据：{len(TRAIN_DATA)} 句，词表大小 {len(clf.vocab)}")

    # 先在之前让规则头疼的测试集上看看
    print("\n  (a) 在演示 2 的测试集上：")
    acc, recall, fp = evaluate_detector(lambda t: clf.predict(t) == "sad", SAD_TEST_CASES)
    print(f"    → 准确率 {acc:.0%}，召回率 {recall:.0%}，误报 {fp} 句")
    print("""
    观察：
      - 没写一条规则，只靠 24 句例句，就认出了 "My heart is heavy and I keep crying"
        （训练集里见过 heart、crying 出现在难过的句子里）。
      - 它同样不懂否定："I am not sad at all" 里的 sad 是很强的"难过"证据，not 却没有任何"反转"作用。
      - "I am miserable" 判对了，但这是**碰巧**：模型不认识 miserable，判断完全来自 "i"、"am"
        这两个和情绪无关的词。下面 (b) 会揭穿这一点。""")

    # 再看"没见过的词"
    print("\n  (b) 用训练集里**从没出现过**的近义词来表达情绪：")
    probes = ["gloomy and miserable", "ecstatic and delighted", "I feel miserable", "I feel delighted"]
    for text in probes:
        scores, known, unknown = clf.log_scores(text)
        gap = scores["sad"] - scores["happy"]
        print(f"    {text!r:<28s} → 预测 {clf.predict(text):<5s}  "
              f"认识的词 {known}，不认识的词 {unknown}，log 得分差(sad-happy)={gap:+.2f}")
    print("""
    观察：
      - "gloomy and miserable" 和 "ecstatic and delighted" 意思完全相反，
        但模型只认识 "and"，两句话得到**完全相同**的得分 —— 它根本分不出来。
      - "I feel miserable" 和 "I feel delighted" 也一样：模型只看到 "i feel"，
        给出的判断完全由 "i"、"feel" 这两个和情绪无关的词决定。""")


# =============================================================================
# 第 4 部分：问题出在哪？—— 每个词都是孤立的符号
# =============================================================================
def demo_symbols_problem():
    """演示 4：把问题说清楚，引出词向量。"""
    print_header("演示 4：为什么没见过的词就没办法？")
    print("""
  在朴素贝叶斯（以及规则系统）眼里，每个词只是一个**编号**：
        sad → 17      miserable → ???（不在词表里）      gloomy → ???
  就算 miserable 在词表里，它和 sad 也只是两个不同的编号，
  模型没有任何途径知道"miserable 和 sad 意思差不多"。

  人类为什么能理解从没见过的 "miserable"？因为我们知道它和 sad、unhappy 相近。
  如果每个词都有一个"含义坐标"，意思相近的词坐标也相近，
  那么模型从 "sad" 学到的知识就能自动迁移到 "miserable" 上。

  这个"含义坐标"就是本讲后半部分的主角 —— **词向量（word vector）**。
  而"怎么在没人标注的情况下得到它"，答案是：看这个词经常和哪些词一起出现
  （分布假说，讲义第 7 节；代码 02、03、05）。""")


# =============================================================================
# 交互模式
# =============================================================================
def chat():
    """和迷你 ELIZA 实时聊天，输入 quit 或按 Ctrl+D 退出。"""
    bot = MiniEliza()
    print("迷你 ELIZA：Hello. How are you feeling today?（输入 quit 退出）")
    while True:
        try:
            text = input("你：")
        except EOFError:                 # 【Python 小知识】按 Ctrl+D 时 input() 会抛出 EOFError
            break
        if text.strip().lower() in {"quit", "exit", "bye"}:
            break
        print("迷你 ELIZA：" + bot.respond(text)[0])
    print("迷你 ELIZA：Goodbye.")


def self_check():
    """
    自我检查：确保讲义中引用的关键行为成立。如果你修改了代码导致这里报错，说明改变了这些结论。
    """
    bot = MiniEliza()
    assert bot.respond("I am worried about my exam")[0] == "How long have you been worried about your exam?"
    assert reflect("my exam and me") == "your exam and you"
    clf = NaiveBayesClassifier().fit(TRAIN_DATA)
    s1, _, _ = clf.log_scores("gloomy and miserable")
    s2, _, _ = clf.log_scores("ecstatic and delighted")
    assert s1 == s2, "两句只含相同已知词的句子，得分应该完全相同"
    assert rule_based_detect("I am not sad at all", SAD_RULES_V2), "规则应该误判否定句（这是演示的重点）"


if __name__ == "__main__":
    if "--chat" in sys.argv:
        chat()
    else:
        self_check()
        demo_eliza_conversation()
        demo_rule_brittleness()
        demo_naive_bayes()
        demo_symbols_problem()
