"""
=============================================================================
CS230 · 第 6 讲数据模块：合成的"足球场"二维数据（仿 Coursera 编程作业 "Regularization"）
=============================================================================

对应讲义：lectures/lecture06_bias_variance_regularization.md（§4 L2 正则化、§5 dropout、§6.2 early stopping）

-----------------------------------------------------------------------------
【原作业的设定，以及这里为什么要合成】
-----------------------------------------------------------------------------
Coursera "Improving Deep Neural Networks" 第 1 周的编程作业 "Regularization" 讲了一个故事：
你是法国足球队请来的 AI 专家，要建议守门员把球开到场上的什么位置。数据集里每个点是一次开球后
有球员用头碰到球的位置（二维坐标），标签是"碰到球的是法国队员（蓝点）还是对方队员（红点）"。
作业用一个 3 层网络 [2, 20, 3, 1] 学这个分类，比较不加正则化、L2 正则化、dropout 三种做法。

原作业的数据文件（datasets/data.mat）不公开，所以这里**合成**一个相似的数据集。它不是原数据，
只是模仿"二维、有噪声、一条弯曲的分界线、训练样本只有两百个"这几个特点。好处是"真相"由我们决定：

    · 场地：x₁、x₂ 都在 [−0.7, 0.7] 里均匀分布（x₁ 沿球场长度方向，x₂ 沿宽度方向）
    · 真实分界线：x₂ = 0.3 · sin(3.5 · x₁)，线的上方是法国队员的"地盘"
    · 标签的随机性（所以没有 100% 正确的分类器）：
          P(y = 1 | x) = ε + (1 − 2ε) · σ( f(x) / τ ),   f(x) = x₂ − 0.3 · sin(3.5 · x₁)
      - τ = 0.03：分界线附近有一条约 ±0.05 宽的"模糊带"，那里两队都可能抢到球；
      - ε = 0.04：在场上任何地方都有 4% 的"意外"（对方在法国队的地盘里抢到球，或者反过来）。
        这些远离分界线的"意外点"正是没有正则化的网络最想去"包住"的噪声。
    · 训练集 211 个点（和原作业一样多）；dev 集 2000 个、测试集 5000 个（原作业的测试集是 200 个，
      这里用得更多，是为了让准确率本身的随机误差足够小：讲义 §1.3 说的"dev / test 要大到能分辨你关心的差别"）。

这些参数（分界线的形状、ε = 0.04、τ = 0.03）是我在写演示之前定下的，只在草稿里跑过一次确认
"不加正则化的网络会过拟合一点、L2 / dropout 会好一点"这个大致现象确实存在，之后没有为了让结果更好看而调过。

因为知道 P(y = 1 | x)，可以算出 **Bayes 最优分类器**（f(x) > 0 就猜 1）和它的错误率
（Bayes error，任何模型都消不掉的那部分错误，约 6.7%）—— 讲义 §2.3 用它当诊断 bias / variance 的参照。

【形状约定】X.shape == (2, m)，每一列是一个样本；Y.shape == (1, m)，值是 0.0 或 1.0。

【注释标签】【思路】【参数】【返回】【边界】【DL 联系】【numpy 小知识】【Python 小知识】

直接运行本文件会打印数据集的基本统计和一张"真相"的字符画（< 1 秒）：
    python3 field_data.py
"""

import os
import sys

import numpy as np

_HERE = os.path.dirname(os.path.abspath(__file__))
for _d in ("lecture01", "lecture03"):
    _p = os.path.join(_HERE, "..", _d)
    if _p not in sys.path:
        sys.path.insert(0, _p)

# 第 3 讲的字符画工具（只读导入）：背景 ' ' / '.' 是预测类别，o / x 是数据点，大写 = 分错
from plot_tools import ASCII_LEGEND, ascii_boundary, print_lines, save_boundary_grid, side_by_side  # noqa: E402,F401

HALF = 0.7                 # 场地范围 [−0.7, 0.7]²
AMP, FREQ = 0.3, 3.5       # 分界线 x₂ = AMP · sin(FREQ · x₁)
TAU = 0.03                 # 分界线附近"模糊带"的宽度
EPS = 0.04                 # 场上任何地方的"意外"概率
BOUNDS = (-0.75, 0.75, -0.75, 0.75)   # 画字符画的范围（比场地多留 0.05）

# 训练 / dev / 测试集的大小和种子（三个不同的种子：同一个分布，没有一个点是共享的）
M_TRAIN, M_DEV, M_TEST = 211, 2000, 5000
SEEDS = {"train": 61, "dev": 62, "test": 63}


def boundary_score(X):
    """
    f(x) = x₂ − 0.3 · sin(3.5 · x₁)：点在真实分界线上方多远（正 = 法国队的地盘）。

    【参数】X：(2, m)
    【返回】(m,) 的一维数组
    【numpy 小知识】X[0]、X[1] 取第 0、1 行（所有样本的 x₁、x₂），形状都是 (m,)。
    """
    return X[1] - AMP * np.sin(FREQ * X[0])


def p_french(X):
    """
    真实的条件概率 P(y = 1 | x) = ε + (1 − 2ε) · σ(f(x) / τ)。

    【思路】σ(f / τ) 在分界线上方远处接近 1、下方远处接近 0、线上恰好 0.5；τ 越小，过渡越陡。
            再和"意外"混合：以 ε 的概率标签和位置无关地翻转，所以概率最高只到 1 − ε、最低只到 ε。
    【参数】X：(2, m)
    【返回】(m,) 的概率
    【numpy 小知识】0.5 · (1 + tanh(z / 2)) 就是 sigmoid(z)，而且不会溢出（第 1 讲 tiny_nn.sigmoid）。
    """
    s = 0.5 * (1.0 + np.tanh(0.5 * boundary_score(X) / TAU))
    return EPS + (1.0 - 2.0 * EPS) * s


def make_field(m, rng):
    """
    生成 m 个"头球点"和标签。

    【参数】m：样本数；rng：np.random.Generator
    【返回】(X, Y)：形状 (2, m)、(1, m)
    【numpy 小知识】rng.uniform(lo, hi, size) 生成 [lo, hi) 上的均匀随机数；
            (rng.random(m) < p) 以概率 p 得到 True；.astype(float) 把 True/False 变成 1.0/0.0；
            [None, :] 在最前面加一维，把 (m,) 变成 (1, m)。
    """
    X = rng.uniform(-HALF, HALF, (2, m))
    Y = (rng.random(m) < p_french(X)).astype(float)[None, :]
    return X, Y


def get_field_splits(m_train=M_TRAIN, m_dev=M_DEV, m_test=M_TEST):
    """
    返回训练 / dev / 测试三份数据（固定种子，每次调用完全一样）。

    【返回】dict：{"train": (X, Y), "dev": (X, Y), "test": (X, Y)}
    【DL 联系】训练集拟合参数；dev 集选 λ、keep_prob、什么时候停（超参数）；测试集只在最后报告一次成绩（讲义 §1.2）。
    """
    return {name: make_field(m, np.random.default_rng(SEEDS[name]))
            for name, m in (("train", m_train), ("dev", m_dev), ("test", m_test))}


def bayes_predict(X):
    """Bayes 最优分类器：f(x) > 0（P(y = 1 | x) > 0.5）就猜 1。返回 (1, m) 的 0.0 / 1.0。"""
    return (boundary_score(X) > 0).astype(float)[None, :]


def bayes_error(m=400000, seed=999):
    """
    Bayes 错误率的期望：E_x[ min(P(y=1|x), 1 − P(y=1|x)) ]。

    【思路】Bayes 分类器在 x 处猜概率大的那一类，猜错的概率就是 min(p, 1 − p)。对 x 取平均即可 ——
            用 40 万个均匀随机点近似这个平均（随机误差约 0.0001）。这比"生成标签、数错了几个"更准，
            因为没有标签抽样带来的那部分随机性。
    【返回】float（约 0.067）
    【DL 联系】Bayes error（贝叶斯误差 / 最优误差）：任何分类器都不可能低于它。讲义 §2.3 用它当诊断的参照，
            第 10 讲讲真实任务里怎样用"人类水平"来估计它。
    """
    X = np.random.default_rng(seed).uniform(-HALF, HALF, (2, m))
    p = p_french(X)
    return float(np.mean(np.minimum(p, 1.0 - p)))


if __name__ == "__main__":
    d = get_field_splits()
    print(f"Bayes 错误率（期望）≈ {bayes_error():.4f}")
    for name in ("train", "dev", "test"):
        X, Y = d[name]
        err = float(np.mean(bayes_predict(X) != Y))
        print(f"{name:<5} X {X.shape}  Y {Y.shape}  法国队（y = 1）比例 {Y.mean():.3f}  "
              f"Bayes 分类器在这份数据上的错误率 {err:.4f}")
    X, Y = d["train"]
    print("\n真实分界线（Bayes 最优分类器）和 211 个训练点：")
    print_lines(ascii_boundary(bayes_predict, X, Y, bounds=BOUNDS))
    print(ASCII_LEGEND)
