"""
=============================================================================
第 2 讲 · 代码 5：数据决定分布鲁棒性——"有效鲁棒性"的直线，以及什么能让模型离开这条线
=============================================================================

对应讲义：lectures/lecture02_data.md 第 9 节

-----------------------------------------------------------------------------
【这个文件要回答的问题 What this file answers】
-----------------------------------------------------------------------------
Ludwig Schmidt 等人的一系列工作（Taori et al. 2020；Miller et al. 2021；Fang et al. 2022）发现：
在 ImageNet 上训练的几百个模型，不管架构、大小、训练方法，它们在"原测试集"和"分布偏移的测试集"上的准确率
（在 logit 坐标下）几乎落在**同一条直线**上；只有 CLIP 这类在**不同数据**上训练的模型明显高于这条线。
Fang et al. (2022) 进一步证明：CLIP 的鲁棒性来自训练数据的分布，而不是语言监督、对比损失或数据量。

这个文件在一个玩具任务上重现这个结构。任务：读一条评论，判断**食物**好不好（二分类）。评论来自 4 个"网站"：

    来源 A（训练 / 原测试集，相当于 ImageNet）  review: the soup was crisp. the waiter was kind.
    来源 B（分布偏移的测试集，相当于 ImageNet-Sketch）  soup? zesty! service: kind.
    来源 C（另一个网站，用词更杂，覆盖了 B 的一部分说法）   i had the soup, it was zesty. staff kind.
    来源 D（又一个网站，句式不同，但用词和 A 一样）      soup crisp, waiter kind.

    B 里有一半的食物形容词是 A 里**从来没出现过**的（zesty、velvety、mushy、rancid……），
    只有 C 里有它们。所以，一个模型在 B 上能做多好，取决于它的训练数据**覆盖了 B 的多少**。

    演示 1：只用 A 训练 5 种不同的模型（词袋逻辑回归、字符 3-gram 逻辑回归、朴素贝叶斯、最近质心、小 MLP），
            训练集大小从 8 到 1024。它们在 (A 准确率, B 准确率) 平面上（logit 坐标）落在一条直线上吗？
    演示 2：模型不变，只改训练数据的**配比**：A + D（更多样，但没覆盖 B）、A + C（覆盖了 B）、C 占不同比例。
            谁离开了那条直线？"有效鲁棒性"（effective robustness）是多少？

-----------------------------------------------------------------------------
【运行方式】
-----------------------------------------------------------------------------
    python3 05_data_determines_robustness.py          # 约 4 秒

只需要 numpy。依赖第 1 讲的 console.py（只读导入）。

【numpy 小知识】
    - X @ W：矩阵乘法。X 是 (样本数, 特征数)，W 是 (特征数, 输出数)。
    - np.maximum(0, H)：逐元素取最大值，这就是 ReLU 激活函数。
    - np.polyfit(x, y, 1)：最小二乘拟合一条直线，返回 (斜率, 截距)。
"""

import math
import os
import sys

import numpy as np

HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(HERE, "..", "lecture01"))

from console import pad, print_header  # noqa: E402

FOODS = ["soup", "bread", "fish", "cake", "rice", "tea", "pie", "stew"]
STAFF = ["waiter", "staff", "cook", "host"]
SERVICE = ["kind", "quick", "polite", "friendly", "rude", "slow", "grumpy", "careless"]
# 食物形容词：两部分。SHARED 在 A、C、D、B 里都会出现；B_ONLY 只在 B 和 C 里出现，A 和 D 里从来没有。
SHARED_POS = ["tasty", "fresh", "warm", "sweet", "rich", "crisp", "great", "lovely",
              "savory", "juicy", "tender", "hearty"]
SHARED_NEG = ["bland", "stale", "cold", "burnt", "sour", "soggy", "awful", "greasy",
              "salty", "dry", "chewy", "watery"]
B_ONLY_POS = ["zesty", "velvety", "fluffy", "divine", "golden", "smoky"]
B_ONLY_NEG = ["mushy", "rancid", "gritty", "lumpy", "moldy", "rubbery"]

TEMPLATES = {
    "A": "review: the {food} was {adj}. the {staff} was {svc}.",
    "B": "{food}? {adj}! service: {svc}.",
    "C": "i had the {food}, it was {adj}. staff {svc}.",
    "D": "{food} {adj}, {staff} {svc}.",
}
LABEL_NOISE = 0.03     # 3% 的标签是错的（真实数据集都有标注噪声）；它让准确率的上限略低于 1


def adjective_weights(source, positive):
    """
    某个来源里，正面（或负面）食物形容词的出现概率。

    【思路】每个来源有自己的"常用词"：A 和 D 按 Zipf 分布（排第 k 的词概率 ∝ 1/k）使用 SHARED 里的词，
            越靠后的越少见；B 把 SHARED 倒过来排（A 里最少见的词在 B 里最常见），而且一半的概率质量给 B_ONLY；
            C 均匀地使用 SHARED 和 B_ONLY 的所有词。
    【参数】source："A"/"B"/"C"/"D"；positive：True = 正面形容词
    【返回】(words, probs)：词列表和对应的概率（numpy 数组，和为 1）
    """
    shared = SHARED_POS if positive else SHARED_NEG
    b_only = B_ONLY_POS if positive else B_ONLY_NEG
    zipf = np.array([1.0 / (k + 1) for k in range(len(shared))])
    if source in ("A", "D"):
        return shared, zipf / zipf.sum()
    if source == "B":
        p_shared = zipf[::-1] / zipf.sum() * 0.5          # 倒过来排，占一半
        p_only = np.ones(len(b_only)) / len(b_only) * 0.5  # B_ONLY 占另一半
        return shared + b_only, np.concatenate([p_shared, p_only])
    words = shared + b_only                                 # C：所有词均匀
    return words, np.ones(len(words)) / len(words)


def make_reviews(source, n, rng):
    """
    从某个来源生成 n 条评论及标签（1 = 食物好）。

    【参数】source：来源；n：条数；rng：np.random.Generator
    【返回】(texts, labels)：list[str] 和 (n,) 的 0/1 数组
    【边界】标签有 LABEL_NOISE 的概率被翻转（模拟标注错误）；服务形容词和食物好坏无关（干扰项）。
    """
    texts, labels = [], []
    for _ in range(n):
        y = int(rng.random() < 0.5)
        words, probs = adjective_weights(source, positive=bool(y))
        adj = words[rng.choice(len(words), p=probs)]
        texts.append(TEMPLATES[source].format(food=FOODS[rng.integers(len(FOODS))], adj=adj,
                                              staff=STAFF[rng.integers(len(STAFF))],
                                              svc=SERVICE[rng.integers(len(SERVICE))]))
        labels.append(1 - y if rng.random() < LABEL_NOISE else y)
    return texts, np.array(labels, dtype=float)


def make_mixture(parts, n, rng):
    """
    按比例从几个来源混合出 n 条训练数据——这就是"数据配比（data mixture）"。

    【参数】parts：dict，来源 -> 比例（和为 1）；n：总条数；rng：np.random.Generator
    【返回】(texts, labels)，已经打乱
    """
    texts, labels = [], []
    for src, frac in parts.items():
        t, y = make_reviews(src, int(round(n * frac)), rng)
        texts += t
        labels.append(y)
    y = np.concatenate(labels)
    order = rng.permutation(len(texts))
    return [texts[i] for i in order], y[order]


# =============================================================================
# 特征和 5 种模型
# =============================================================================
def words_of(text):
    """把文本切成词（小写字母组成的片段）。"""
    out, cur = [], ""
    for c in text:
        if c.isalpha():
            cur += c
        elif cur:
            out.append(cur)
            cur = ""
    return out + ([cur] if cur else [])


class Featurizer:
    """
    把文本变成特征向量。kind = "word"：词袋（每个词出现几次）；kind = "char3"：字符 3-gram 袋
    （每个词前后加上边界符 "^"、"$"，例如 "^ta"、"tas"、"ast"、"sty"、"ty$"）。

    【思路】特征表在 fit 时从**训练数据**里建立。测试时遇到训练里没见过的特征，直接忽略——
            这正是"没见过的词，模型一无所知"的来源。字符 3-gram 的好处是：没见过的词也可能和见过的词
            共享一些片段（例如 -y 结尾），可能带来一点点泛化。
    """

    def __init__(self, kind):
        """kind："word" 或 "char3"。特征表在 fit 时建立。"""
        self.kind = kind
        self.index = {}

    def _feats(self, text):
        """一段文本的特征名列表（词，或每个词的字符 3-gram）。"""
        ws = words_of(text)
        if self.kind == "word":
            return ws
        out = []
        for w in ws:
            w = "^" + w + "$"
            out += [w[i:i + 3] for i in range(len(w) - 2)]
        return out

    def fit(self, texts):
        """从训练文本建立特征表（排好序，保证每次运行列号相同）。【返回】self，方便链式调用。"""
        names = sorted({f for t in texts for f in self._feats(t)})
        self.index = {f: i for i, f in enumerate(names)}
        return self

    def transform(self, texts):
        """文本 → (样本数, 特征数) 的计数矩阵；特征表里没有的特征直接忽略。"""
        X = np.zeros((len(texts), len(self.index)))
        for r, t in enumerate(texts):
            for f in self._feats(t):
                j = self.index.get(f)
                if j is not None:
                    X[r, j] += 1.0
        return X


def fit_logreg(X, y, l2, steps=300, lr=0.5):
    """
    带 L2 正则的逻辑回归（全批量梯度下降）。【返回】predict 函数：X -> 0/1 预测
    【思路】判别式模型：直接学 p(y | x)。
    """
    w, b = np.zeros(X.shape[1]), 0.0
    for _ in range(steps):
        p = 1 / (1 + np.exp(-(X @ w + b)))
        w -= lr * (X.T @ (p - y) / len(y) + l2 * w)
        b -= lr * float((p - y).mean())
    return lambda Z: (Z @ w + b > 0).astype(float)


def fit_naive_bayes(X, y, alpha=1.0):
    """
    多项式朴素贝叶斯：生成式模型，学 p(词 | 类别)，再用贝叶斯公式反推 p(类别 | 文本)。

    【思路】和逻辑回归的**训练目标完全不同**（最大化 p(x, y) 而不是 p(y | x)），用来检验"目标函数会不会改变鲁棒性"。
    【参数】alpha：拉普拉斯平滑（每个词的计数先加 alpha，避免没见过的词概率为 0）
    """
    logp = []
    for c in (0, 1):
        counts = X[y == c].sum(axis=0) + alpha
        logp.append(np.log(counts / counts.sum()))
    prior = np.log(np.array([np.mean(y == 0), np.mean(y == 1)]) + 1e-9)
    return lambda Z: ((Z @ logp[1] + prior[1]) > (Z @ logp[0] + prior[0])).astype(float)


def fit_centroid(X, y):
    """
    最近质心：每个类别的特征向量取平均（"原型"），测试样本归到余弦相似度更高的那个原型。

    【思路】和 CLIP 的零样本分类在形式上相似——都是"和每个类别的代表向量比相似度"。
    """
    def norm(M):
        """每一行除以它的 L2 范数（加 1e-9 防止除以 0），之后点积就是余弦相似度。"""
        return M / (np.linalg.norm(M, axis=-1, keepdims=True) + 1e-9)
    c0, c1 = norm(X[y == 0].mean(axis=0)), norm(X[y == 1].mean(axis=0))
    return lambda Z: (norm(Z) @ c1 > norm(Z) @ c0).astype(float)


def fit_mlp(X, y, seed, hidden=32, steps=500, lr=0.2, l2=1e-3):
    """
    一个隐藏层的小神经网络（ReLU），全批量梯度下降。手写前向和反向传播。

    【思路】架构和线性模型不同：能学非线性的组合。用来检验"架构会不会改变鲁棒性"。
    【参数】hidden：隐藏单元数；seed：初始化的随机种子
    【返回】predict 函数
    【边界】学习率太大时训练会"崩掉"（ReLU 单元全部死掉，模型只会猜一个类别）。lr = 0.5 时就遇到过一次，
            所以这里用 0.2、多训练几步。
    【numpy 小知识】反向传播就是链式法则：dL/dW2 = H^T · dL/dlogit，dL/dH = dL/dlogit · W2^T，
            再乘上 ReLU 的导数（H > 0 的地方是 1，其余是 0），得到 dL/dW1 = X^T · dL/d(XW1)。
    """
    rng = np.random.default_rng(seed)
    W1 = rng.normal(0, 0.1, (X.shape[1], hidden))
    b1 = np.zeros(hidden)
    W2 = rng.normal(0, 0.1, hidden)
    b2 = 0.0
    n = len(y)
    for _ in range(steps):
        H = np.maximum(0, X @ W1 + b1)                 # (n, hidden)
        p = 1 / (1 + np.exp(-(H @ W2 + b2)))
        g = (p - y) / n                                # dL/dlogit
        gW2 = H.T @ g + l2 * W2
        gH = np.outer(g, W2) * (H > 0)                 # 反传到隐藏层，乘 ReLU 的导数
        gW1 = X.T @ gH + l2 * W1
        W1 -= lr * gW1
        b1 -= lr * gH.sum(axis=0)
        W2 -= lr * gW2
        b2 -= lr * float(g.sum())
    return lambda Z: ((np.maximum(0, Z @ W1 + b1) @ W2 + b2) > 0).astype(float)


MODELS = {
    "词袋 + 逻辑回归": ("word", lambda X, y, s: fit_logreg(X, y, l2=1e-3)),
    "字符3-gram + 逻辑回归": ("char3", lambda X, y, s: fit_logreg(X, y, l2=1e-3)),
    "词袋 + 朴素贝叶斯": ("word", lambda X, y, s: fit_naive_bayes(X, y)),
    "词袋 + 最近质心": ("word", lambda X, y, s: fit_centroid(X, y)),
    "词袋 + 小 MLP": ("word", lambda X, y, s: fit_mlp(X, y, seed=s)),
}


def train_and_eval(model_name, train, tests, seed):
    """
    训练一个模型，在每个测试集上算准确率。

    【参数】model_name：MODELS 里的名字；train：(texts, labels)；tests：dict，名字 -> (texts, labels)；seed：随机种子
    【返回】dict：测试集名 -> 准确率
    """
    kind, fit = MODELS[model_name]
    feat = Featurizer(kind).fit(train[0])
    predict = fit(feat.transform(train[0]), train[1], seed)
    return {k: float((predict(feat.transform(t)) == y).mean()) for k, (t, y) in tests.items()}


def logit(p):
    """logit(p) = ln(p / (1 − p))。准确率先截断到 [0.501, 0.999]，避免无穷大。"""
    p = min(max(p, 0.501), 0.999)
    return math.log(p / (1 - p))


def sigmoid(z):
    """logit 的反函数。"""
    return 1 / (1 + math.exp(-z))


def ascii_scatter(points, line, width=64, height=18):
    """
    在终端里画散点图：横轴 logit(A 准确率)，纵轴 logit(B 准确率)，坐标轴标的是准确率本身。

    【参数】points：list[(x_acc, y_acc, 字符)]；line：(斜率, 截距)，在 logit 空间里画出来（用 "·"）
    """
    xs = [logit(p[0]) for p in points]
    ys = [logit(p[1]) for p in points]
    x0, x1 = min(xs) - 0.1, max(xs) + 0.1
    y0, y1 = min(ys) - 0.1, max(ys) + 0.1
    grid = [[" "] * width for _ in range(height)]
    for c in range(width):
        x = x0 + (x1 - x0) * c / (width - 1)
        r = int(round((y1 - (line[0] * x + line[1])) / (y1 - y0) * (height - 1)))
        if 0 <= r < height:
            grid[r][c] = "·"
    for (xa, ya, ch), x, y in zip(points, xs, ys):
        c = int(round((x - x0) / (x1 - x0) * (width - 1)))
        r = int(round((y1 - y) / (y1 - y0) * (height - 1)))
        grid[r][c] = ch
    for i, row in enumerate(grid):
        label = f"{sigmoid(y1 - (y1 - y0) * i / (height - 1)):.2f}" if i % 4 == 0 else ""
        print(pad(label, 6, ">") + " |" + "".join(row))
    print(" " * 7 + "+" + "-" * width)
    ticks = "".join(pad(f"{sigmoid(x0 + (x1 - x0) * k / 4):.2f}", width // 4) for k in range(4))
    print(" " * 8 + ticks + f"{sigmoid(x1):.2f}")
    print(" " * 8 + "横轴：A（原分布）准确率；纵轴：B（偏移后）准确率；坐标按 logit 缩放")


# =============================================================================
# 演示 1：只用 A 训练的各种模型落在一条直线上
# =============================================================================
def demo_line(tests):
    """
    演示 1：只用来源 A 训练 5 种模型 × 7 种训练集大小，在 logit 坐标里拟合"准确率直线"，算每种模型的有效鲁棒性。

    【参数】tests：dict，"A" / "B" -> (texts, labels)
    【返回】(slope, intercept, rows)：直线的斜率、截距，和每个模型的 (名字, n, A 准确率, B 准确率)
    """
    print_header("演示 1：只用 A 训练 5 种模型、7 种训练集大小（8 到 1024）—— (A 准确率, B 准确率) 落在一条直线上吗？")
    sizes = [8, 16, 32, 64, 128, 256, 1024]
    rows = []
    for mi, name in enumerate(MODELS):
        for si, n in enumerate(sizes):
            train = make_mixture({"A": 1.0}, n, np.random.default_rng(1000 + 10 * si))
            acc = train_and_eval(name, train, tests, seed=mi)
            rows.append((name, n, acc["A"], acc["B"]))
    print(pad("模型", 24) + "".join(pad(f"n={n}", 13, ">") for n in sizes))
    for name in MODELS:
        cells = [f"{a:.2f}/{b:.2f}" for nm, n, a, b in rows if nm == name]
        print(pad(name, 24) + "".join(pad(c, 13, ">") for c in cells))
    print("（每格：A 测试集准确率 / B 测试集准确率；同一个 n 的 5 种模型用的是同一份训练数据）")
    x = np.array([logit(a) for _, _, a, _ in rows])
    y = np.array([logit(b) for _, _, _, b in rows])
    slope, intercept = np.polyfit(x, y, 1)
    resid = y - (slope * x + intercept)
    r2 = 1 - (resid ** 2).sum() / ((y - y.mean()) ** 2).sum()
    print(f"\n在 logit 坐标里拟合一条直线：logit(B) = {slope:.3f} × logit(A) + {intercept:+.3f}，R² = {r2:.3f}")
    print("每种模型的平均\"有效鲁棒性\" ρ = B 准确率 − 直线在同样 A 准确率处预测的 B 准确率：")
    for name in MODELS:
        rho = [b - sigmoid(slope * logit(a) + intercept) for nm, _, a, b in rows if nm == name]
        print(f"  {pad(name, 24)} ρ = {np.mean(rho):+.3f}   （最小 {min(rho):+.3f}，最大 {max(rho):+.3f}）")
    print("""
观察 Observations：
  · 5 种模型（判别式 / 生成式的训练目标、线性 / 非线性、词 / 字符特征、"和原型比相似度"的最近质心）× 7 种训练集大小，
    35 个点在 logit 坐标下几乎落在同一条直线上（R² = 0.948）。训练集越大，点沿着直线往右上走：
    A 准确率从 0.6 多升到 0.97，B 准确率跟着从 0.53 升到 0.7 出头。
  · 每种模型的平均有效鲁棒性都在 ±0.02 以内：换训练目标、换架构、加数据量，都只是让模型**沿着直线移动**，
    没有离开它。这就是 Taori et al. (2020) 在 204 个 ImageNet 模型上看到的现象（Miller et al. 2021 称之为
    "accuracy on the line"）。
  · 唯一稍稍高出直线的是字符 3-gram（平均 +0.017，最多 +0.049）：B 独有的形容词和 A 里的词共享一些字母片段
    （zesty 和 tasty 都以 -sty 结尾），字符特征借此多猜对了一点。特征的归纳偏置有一点作用，但很小——
    演示 2 会看到，数据的作用大一个数量级。
  · 直线很平（斜率 0.362）：B 里一半的形容词 A 里根本没有，只用 A 训练的模型在这一半上只能瞎猜，
    所以 B 准确率的"天花板"大约是 0.5 × 0.97 + 0.5 × 0.5 ≈ 0.73。这个天花板由**数据**决定，不由模型决定。""")
    return slope, intercept, rows


# =============================================================================
# 演示 2：只改训练数据的配比
# =============================================================================
def demo_mixtures(tests, line, rows_a):
    """
    演示 2：模型不变，只改训练数据的配比，看有效鲁棒性怎样变化；最后画散点图。

    【参数】tests：同演示 1；line：(斜率, 截距)；rows_a：演示 1 的结果（散点图里的 a 点）
    【返回】无（只打印）
    """
    slope, intercept = line
    print_header("演示 2：模型不变，只改训练数据的配比 —— 谁离开了那条直线？")
    mixes = {
        "A（基线）": {"A": 1.0},
        "A 50% + D 50%（句式更多样，但用词和 A 一样）": {"A": 0.5, "D": 0.5},
        "A 90% + C 10%": {"A": 0.9, "C": 0.1},
        "A 75% + C 25%": {"A": 0.75, "C": 0.25},
        "A 50% + C 50%（覆盖了 B 的说法）": {"A": 0.5, "C": 0.5},
        "C 100%": {"C": 1.0},
    }
    marks = {"A（基线）": "a", "A 50% + D 50%（句式更多样，但用词和 A 一样）": "d", "A 50% + C 50%（覆盖了 B 的说法）": "c"}
    print("每种配比 × 3 种训练集大小（256、1024、4096）× 2 种模型（词袋逻辑回归、小 MLP）；表里是平均值。\n")
    print(pad("训练数据配比", 46) + pad("A 准确率", 10, ">") + pad("B 准确率", 10, ">") + pad("有效鲁棒性 ρ", 14, ">"))
    pts = [(a, b, "a") for nm, n, a, b in rows_a if nm == "词袋 + 逻辑回归"]
    for mi, (label, parts) in enumerate(mixes.items()):
        res = []
        for si, n in enumerate([256, 1024, 4096]):
            train = make_mixture(parts, n, np.random.default_rng(2000 + 10 * si + 100 * mi))
            for name in ("词袋 + 逻辑回归", "词袋 + 小 MLP"):
                acc = train_and_eval(name, train, tests, seed=si)
                rho = acc["B"] - sigmoid(slope * logit(acc["A"]) + intercept)
                res.append((acc["A"], acc["B"], rho))
                if label in marks:
                    pts.append((acc["A"], acc["B"], marks[label]))
        a, b, r = (np.mean([x[i] for x in res]) for i in range(3))
        print(pad(label, 46) + pad(f"{a:.3f}", 10, ">") + pad(f"{b:.3f}", 10, ">") + pad(f"{r:+.3f}", 14, ">"))
    print("\n散点图（a = 只用 A 训练的词袋逻辑回归，各种大小；d = A + D；c = A + C；· = 演示 1 拟合的直线）：\n")
    ascii_scatter(pts, line)
    print("""
观察 Observations：
  · 同样的模型、几乎同样的 A 准确率（都在 0.96 左右），只改训练数据的配比，B 准确率从 0.727 升到 0.956，
    有效鲁棒性 +0.218。散点图里 c 点远远高出直线。
  · 但"更多样"本身不够：A + D 的句式和 A 完全不同，有效鲁棒性却是 −0.013，d 点就在直线上——D 的用词和 A 一样，
    没有覆盖 B 的那些说法。起作用的不是抽象的"多样性"，而是训练分布**覆盖了测试时会遇到的东西**。
  · 一点点对的数据就有很大作用：只混入 10% 的 C，有效鲁棒性就到了 +0.134。反过来，C 100% 在 B 上是 0.965，
    混入一半的 A 后降到 0.956——把鲁棒的来源和不鲁棒的来源混在一起会**稀释**鲁棒性，方向和 Nguyen et al. (2022)
    在 CLIP 上看到的一致（我们的玩具里稀释得很少）。
  · 这就是 Fang et al. (2022) 关于 CLIP 的结论的玩具版：CLIP 的鲁棒性来自它的训练分布，而不是语言监督、
    对比损失或数据量。想让模型在新分布上更可靠，最直接的杠杆是训练数据（第 14 讲会回到 CLIP）。
  · 玩具的局限：这里的"覆盖"是构造出来的（C 里写着 B 的词）。真实世界里，我们往往不知道部署时会遇到什么分布，
    也不知道网络数据到底覆盖了什么——这正是 LAION、DataComp 这类"可以拿来做实验的数据集"存在的意义。""")


if __name__ == "__main__":
    test_rng = np.random.default_rng(7)
    tests = {"A": make_reviews("A", 2000, test_rng), "B": make_reviews("B", 2000, test_rng)}
    print("四个来源各一条样例：")
    for src in "ABCD":
        t, y = make_reviews(src, 1, np.random.default_rng(3))
        print(f"  {src}: {t[0]}   （标签：食物{'好' if y[0] else '差'}）")
    slope, intercept, rows_a = demo_line(tests)
    demo_mixtures(tests, (slope, intercept), rows_a)
