"""
=============================================================================
CS230 · 第 1 讲共享模块：合成数据集（synthetic datasets with known truth）
=============================================================================

对应讲义：lectures/lecture01_intro_deep_learning.md（后面几讲也会导入这个模块）

-----------------------------------------------------------------------------
【这个模块里有什么 What's inside】
-----------------------------------------------------------------------------
第 1 讲的所有实验都用"自己造的"数据。为什么不用真实数据集（比如 ImageNet、房价数据）？

    1. 不需要下载，任何电脑上几秒钟就能跑完；
    2. **我们知道真相**：数据是按我们写下的公式生成的，所以能检查网络到底学到了什么
       （比如"隐藏层是不是学出了步行便利度"——真实数据里根本没有这个变量的标签）；
    3. 可以随意控制数据量：第 1 讲的核心实验就是"数据从 100 个增加到 100,000 个会怎样"。

本模块提供三个"世界"：

    世界                     函数                         用在哪里
    一维房价（面积 → 价格）    make_housing_1d              代码 01 演示 1：一个 ReLU 神经元
    五维房价（含隐藏因素）     make_housing                 代码 01 演示 2–3：网络自动学出中间特征
    "老师网络"分类任务         make_teacher, make_teacher_data,
                              expert_features              代码 03（数据规模）、代码 04（激活函数）

-----------------------------------------------------------------------------
【记号约定 Conventions】（全课统一，见讲义 §0.4）
-----------------------------------------------------------------------------
    · m   = 样本数（number of examples）
    · n_x = 每个样本的特征数（input size）
    · X 的形状是 (n_x, m)：**每一列是一个样本**（Andrew Ng 的约定，和很多 numpy / PyTorch 教程"每一行是
      一个样本"相反；为什么这样约定见讲义 §3.4 和代码 02）
    · Y 的形状是 (1, m)：一行，每列是对应样本的标签

【注释标签】
    【思路】为什么这样写   【参数】【返回】输入输出   【边界】什么时候会出问题
    【DL 联系】这个函数和深度学习的哪个概念有关、后面哪一讲会深入
    【numpy 小知识】/【Python 小知识】第一次出现的写法
"""

import numpy as np


# =============================================================================
# 1. 一维房价：面积 → 价格（Andrew Ng 在 C1M1 里用的第一个例子）
# =============================================================================
def make_housing_1d(m, rng):
    """
    生成 m 套房子的 (面积, 价格)。价格和面积的关系是一条"折线"：小于 30 m² 的房子几乎卖不出价钱，
    超过 30 m² 之后每平方米涨 3（单位：千美元，$k）。

    【思路】真实规律（我们"上帝视角"知道的）是
                price_true = 3 · max(0, size − 30)
            观测到的价格再加上一点噪声（同样面积的房子价格也会不同），并且价格不能是负数：
                price = max(0, price_true + 噪声)
            这正是 Andrew Ng 课上画的那条"先贴着 0、再往上走"的曲线 —— 它的形状就是 ReLU 函数。
            这个"折点在 30"的设定是玩具设定，只为了让折线清晰可见。

    【参数】
        m   : int，样本数，应 ≥ 1
        rng : np.random.Generator，随机数生成器（由调用者传入，保证可复现）
    【返回】
        size  : 形状 (m,) 的数组，面积，均匀分布在 [10, 200] m²
        price : 形状 (m,) 的数组，价格（$k），≥ 0
    【边界】这里返回的是一维数组（形状 (m,)），因为代码 01 演示 1 只有一个特征，用一维数组最直观；
            多特征的数据才用 (n_x, m) 的矩阵。

    【numpy 小知识】
        rng.uniform(low, high, size) ：在 [low, high) 上均匀采样 size 个数
        rng.normal(mean, std, size)  ：正态分布采样
        np.maximum(a, b)             ：**逐元素**取较大值（注意不是 np.max，np.max 是求整个数组的最大值）
    """
    size = rng.uniform(10, 200, m)
    price_true = 3.0 * np.maximum(0.0, size - 30.0)
    price = np.maximum(0.0, price_true + rng.normal(0.0, 20.0, m))
    return size, price


# =============================================================================
# 2. 五维房价：价格由三个"看不见"的因素决定
# =============================================================================
HOUSING_FEATURES = ["size", "bedrooms", "loc_x", "loc_y", "wealth"]
HOUSING_LATENTS = ["family_size", "walkability", "school_quality"]


def make_housing(m, rng):
    """
    生成 m 套房子：5 个可以观测到的输入特征，3 个"看不见"的中间因素，以及价格。

    【思路】这是 Andrew Ng 在 C1M1 "What is a Neural Network?" 里画的那张图的可运行版本：

            输入 x（能观测到）          中间因素（看不见）              输出 y
            size（面积 m²）     ─┐
            bedrooms（卧室数）  ─┴──→  family_size（能住几口人）  ─┐
            loc_x, loc_y（位置）───→  walkability（步行便利度） ─┼──→  price（价格）
            loc_x + wealth     ─────→  school_quality（学区质量）─┘

        · family_size  = size/100 + 0.4·bedrooms                   （输入的**线性**组合）
        · walkability  = exp(−‖loc − 市中心‖² / 0.05)              （到市中心距离的**非线性**函数：
                                                                    市中心最高，往四周衰减，像一座小山）
        · school_quality = sigmoid(10·(wealth − 0.55))            （财富的 S 形函数：过了某个门槛学区就好）
        · price = 30 + 60·family_size + 120·walkability + 100·school_quality + 噪声（$k）

        原课里"邮编 zip code"决定步行便利度；这里用二维坐标 (loc_x, loc_y) 代替邮编，因为坐标是数字，
        可以直接喂给网络（邮编是类别，需要 one-hot 编码，第 2 讲以后再说）。

        **关键点**：步行便利度是坐标的"山峰形"函数。任何**线性**模型 w₁·loc_x + w₂·loc_y + b 都只能表示一个
        "斜坡"，表示不了"中间高、四周低"。所以线性回归会漏掉它，而带隐藏层的网络可以把它拼出来。

    【参数】
        m   : int，样本数
        rng : np.random.Generator
    【返回】dict，包含
        "X"       : (5, m) 输入矩阵，行的顺序见 HOUSING_FEATURES
        "Y"       : (1, m) 价格
        "latents" : dict，三个中间因素，每个是 (m,) 数组（只用来"检查网络学到了什么"，训练时不给网络看）
        "noise_free" : (m,) 没有噪声的价格（用来算"任何模型都无法超过"的 R² 上限）
    【边界】wealth 被截断在 [0, 1] 之间；bedrooms 被截断在 1–6 间的整数。

    【numpy 小知识】
        np.round(a)          ：四舍五入（结果仍是浮点数，比如 3.0）
        np.clip(a, lo, hi)   ：把小于 lo 的值改成 lo、大于 hi 的值改成 hi
        np.vstack([a, b, c]) ：把几个形状相同的一维数组"竖着叠"成矩阵，每个数组成为一行
        a ** 2               ：逐元素平方
    """
    size = rng.uniform(40, 250, m)
    bedrooms = np.clip(np.round(size / 45 + rng.normal(0, 0.7, m)), 1, 6)
    loc_x = rng.uniform(0, 1, m)
    loc_y = rng.uniform(0, 1, m)
    # 城市东边（loc_x 大）更富裕，再加上个体差异
    wealth = np.clip(0.3 + 0.5 * loc_x + rng.normal(0, 0.15, m), 0, 1)

    family_size = size / 100 + 0.4 * bedrooms
    dist2 = (loc_x - 0.5) ** 2 + (loc_y - 0.5) ** 2          # 到市中心 (0.5, 0.5) 的距离的平方
    walkability = np.exp(-dist2 / 0.05)
    school_quality = 1.0 / (1.0 + np.exp(-10 * (wealth - 0.55)))

    noise_free = 30 + 60 * family_size + 120 * walkability + 100 * school_quality
    price = noise_free + rng.normal(0, 10, m)

    X = np.vstack([size, bedrooms, loc_x, loc_y, wealth])     # 形状 (5, m)
    Y = price[None, :]                                        # 形状 (1, m)
    latents = {"family_size": family_size, "walkability": walkability, "school_quality": school_quality}
    return {"X": X, "Y": Y, "latents": latents, "noise_free": noise_free}


# =============================================================================
# 3. "老师网络"分类任务：一个复杂但已知的决策边界
# =============================================================================
def make_teacher(d=10, width=64, gain=2.0, seed=0):
    """
    构造一个随机的"老师网络"（teacher network）。它定义了一个二分类任务：标签 = 1{老师网络的输出 > 中位数}。

    【思路】我们想要一个"复杂到逻辑回归学不会、但又有确定答案"的任务，来模拟现实中的
            "图像 → 是不是猫"这类映射。做法是 teacher–student 设定（学习理论里常用）：
                x ~ N(0, I_d)（d 维标准正态）
                score(x) = 老师网络（d → width → width → 1，tanh 激活，权重随机）
                y = 1{score(x) > 阈值}
            · gain 控制老师权重的大小：gain 越大，tanh 越"弯"，决策边界越复杂，线性模型越吃力；
            · 阈值取 score 在大量样本上的**中位数**，所以正负类各占一半（准确率 50% = 瞎猜）；
            · 没有标签噪声：理论上存在一个 100% 准确的分类器（老师自己），所以"还差多少"一目了然。

        **诚实的说明**：老师本身是一个神经网络，所以"学生网络"的函数族天然包含正确答案。这让任务对神经网络
        "友好"；但现实里的映射（像素 → 物体类别）同样是高度非线性的复合函数，深度网络在那里也表现出类似的
        规模规律。这个任务只用来演示**趋势**，不代表任何具体的真实任务。

    【参数】
        d     : int，输入维度
        width : int，老师网络每个隐藏层的宽度
        gain  : float，权重的放大倍数（> 0）
        seed  : int，生成老师权重的种子。**同一个 seed = 同一个任务**；训练集、测试集必须用同一个老师。
    【返回】dict：W1, b1, W2, b2, W3, b3（老师的参数）、"threshold"（阈值）、"d"
    【边界】阈值是用固定种子的 100,000 个样本估计的，所以测试集上正类比例约为 50%，但不会恰好 50%。

    【DL 联系】teacher–student 设定在理论研究里很常用（比如研究"需要多少数据才能学会"）。
            "随机初始化 + 乘一个 gain"也正是第 7 讲讲初始化时的核心问题：gain 太大，tanh 会饱和。
    """
    rng = np.random.default_rng(seed)
    dims = [d, width, width, 1]
    T = {"d": d}
    for l in range(1, 4):
        # 标准差 sqrt(2 / fan_in) 再乘 gain —— 让每层的输入大致保持在 tanh 的"弯曲区"
        T[f"W{l}"] = gain * rng.normal(0, np.sqrt(2.0 / dims[l - 1]), (dims[l], dims[l - 1]))
        T[f"b{l}"] = rng.normal(0, 0.5, (dims[l], 1))
    X_ref = rng.normal(size=(d, 100_000))
    T["threshold"] = float(np.median(teacher_score(T, X_ref)))
    return T


def teacher_score(T, X):
    """
    老师网络的"原始分数"（还没和阈值比较）。

    【参数】T：make_teacher 的返回值；X：(d, m) 输入
    【返回】(1, m) 数组
    【numpy 小知识】W @ X 是矩阵乘法；W 的形状 (n_out, n_in)，X 的形状 (n_in, m)，结果 (n_out, m)。
            b 的形状是 (n_out, 1)，加到 (n_out, m) 上时会自动"广播"到每一列（代码 02 详细讲广播）。
    """
    h1 = np.tanh(T["W1"] @ X + T["b1"])
    h2 = np.tanh(T["W2"] @ h1 + T["b2"])
    return T["W3"] @ h2 + T["b3"]


def make_teacher_data(T, m, rng):
    """
    从老师定义的任务里采样 m 个带标签的样本。

    【参数】T：老师；m：样本数；rng：随机数生成器（训练集、验证集、测试集用不同的 rng）
    【返回】(X, Y)：X 形状 (d, m)，Y 形状 (1, m)，取值 0.0 / 1.0（浮点数，方便直接参与运算）
    【Python 小知识】(布尔数组).astype(float) 把 True/False 变成 1.0/0.0。
    """
    X = rng.normal(size=(T["d"], m))
    Y = (teacher_score(T, X) > T["threshold"]).astype(float)
    return X, Y


def expert_features(T, X, k=32):
    """
    "领域专家手工设计的特征"：原始输入 x，再加上老师第一层 64 个中间特征里的前 k 个。

    【思路】Andrew Ng 讲"为什么深度学习现在起飞"时强调：在**小数据**区间，算法之间的排序并不确定，
            谁做得好往往取决于"手工设计特征"（feature engineering）的功力。这里我们模拟一位很有经验的专家：
            TA 知道真实规律里的**一部分**中间特征（老师第一层的 k 个单元），把它们和原始输入一起交给逻辑回归。
            · 专家特征让逻辑回归在小数据时有"先发优势"（不用从数据里学这些特征）；
            · 但专家只知道一部分（k < 64），而且第二层的组合方式 TA 也不知道，所以数据再多，这个模型也会停在一个平台上。

    【参数】T：老师；X：(d, m) 输入；k：专家知道的中间特征个数，0 ≤ k ≤ width
    【返回】(d + k, m) 的特征矩阵
    【边界】k = 0 时就退化成原始输入 X。k 取多少是"玩具设定"：这里 k = 32（一半）是为了让曲线清楚，
            讲义里会说明这一点。
    """
    h1 = np.tanh(T["W1"] @ X + T["b1"])
    return np.vstack([X, h1[:k]])


# =============================================================================
# 自检：直接运行本文件时，打印每个世界的基本统计
# =============================================================================
if __name__ == "__main__":
    rng = np.random.default_rng(0)
    size, price = make_housing_1d(1000, rng)
    print(f"一维房价：面积范围 [{size.min():.1f}, {size.max():.1f}] m²，价格范围 [{price.min():.1f}, {price.max():.1f}] $k，"
          f"价格为 0 的比例 {np.mean(price == 0):.1%}")

    H = make_housing(2000, np.random.default_rng(1))
    print(f"五维房价：X 形状 {H['X'].shape}，Y 形状 {H['Y'].shape}")
    for name, row in zip(HOUSING_FEATURES, H["X"]):
        print(f"  {name:<10} 均值 {row.mean():8.3f}  标准差 {row.std():7.3f}")
    for name, v in H["latents"].items():
        print(f"  [latent] {name:<15} 均值 {v.mean():6.3f}  标准差 {v.std():6.3f}")

    T = make_teacher()
    X, Y = make_teacher_data(T, 20000, np.random.default_rng(2))
    print(f"老师任务：X 形状 {X.shape}，正类比例 {Y.mean():.3f}，专家特征形状 {expert_features(T, X).shape}")
