"""
=============================================================================
第 5 讲 · 代码 1：白天还是黑夜 —— 输入图片要多大的分辨率？
=============================================================================

对应讲义：lectures/lecture05_deep_learning_intuition.md 第 2 节（案例 1：Day'n'Night）

-----------------------------------------------------------------------------
【这个文件要回答的问题 What this file answers】
-----------------------------------------------------------------------------
CS230 的热身案例："给一张照片，判断它是白天（y = 0）还是夜晚（y = 1）拍的。"
Kian 的五问里，最有意思的是第二问：**输入的分辨率选多大？**
    · 太大：输入维度 n_x = 高 × 宽 × 3 很大 → 第一层参数多、算得慢、需要更多数据才不会过拟合；
    · 太小：细节被平均掉了，信息不够，谁来都分不出。
Kian 的办法：**把图片缩小到不同分辨率，看人还能不能分辨。** 这个文件用一个合成的照片世界（daynight_world.py）
把这个办法走一遍，再看真正训练出来的模型怎么说：

    演示 1：看图。"阴天"和"城市夜景"在 32×32、16×16、8×8、4×4 下的字符画 —— 你自己当一次"人"。
    演示 2：人类代理 vs 分辨率。用一个手写的"人类规则"在每个分辨率上判断 3000 张测试图，
            找出"人还能分辨"的最小分辨率；并说明这个世界的准确率上限（Bayes 最优）为什么是 95%。
    演示 3：两个模型（逻辑回归、一层隐藏层的网络）在 6 个分辨率上训练（8000 张训练图）：
            准确率、参数个数、每张图的计算量。
    演示 4：数据量 × 分辨率：只有 500 / 2000 张训练图时，最好的分辨率还是同一个吗？

-----------------------------------------------------------------------------
【运行方式】
-----------------------------------------------------------------------------
    python3 01_day_night_resolution.py          # 约 20 秒（因电脑而异）

需要 numpy；装了 matplotlib 会额外保存 figures/lecture05_resolution.png（没装就只打印表格）。
依赖本目录的 case_tools.py、daynight_world.py，以及第 1 讲的 tiny_nn.py（当作黑盒训练器）、console.py。
同一台机器上重复运行，输出逐字相同。
"""

import os

import numpy as np

from case_tools import HERE, ascii_image, luminance, pad, print_header, print_panels
from daynight_world import (BAYES_CEILING, CITY_NIGHT, OVERCAST, SCENES, SIZE, human_proxy_accuracy,
                            make_day_night, to_X)
from tiny_nn import Standardizer, accuracy, count_parameters, predict, train

RESOLUTIONS = [32, 16, 8, 4, 2, 1]
N_HIDDEN = 64            # 网络的隐藏层宽度：[n_x, 64, 1]
N_POOL = 8000            # 训练集最多 8000 张（CS230 课件对这个任务的估计是 10,000 张左右）


def make_splits():
    """
    生成训练 / 验证 / 测试三份数据（三个不同的种子，互不重叠）。

    【返回】dict：每份是 (imgs, y, scenes)
            train：8000 张（演示 4 只取前 500 / 2000 张）；val：1000 张（挑"最好的一步"）；test：3000 张
    【边界】所有演示共用这三份数据，比较才公平；数据是固定种子生成的，每次运行完全一样。
    """
    return {
        "train": make_day_night(N_POOL, np.random.default_rng(501)),
        "val": make_day_night(1000, np.random.default_rng(502)),
        "test": make_day_night(3000, np.random.default_rng(503)),
    }


# =============================================================================
# 演示 1：看图
# =============================================================================
def first_of(scenes, scene):
    """返回 scenes 里第一张等于 scene 的图的下标。np.flatnonzero(布尔数组) 返回所有 True 的位置。"""
    return int(np.flatnonzero(scenes == scene)[0])


def demo_look(data):
    print_header("演示 1：看图 —— 阴天（白天）和城市夜景（夜晚），在四个分辨率下")
    imgs, y, scenes = data["test"]
    i, j = first_of(scenes, OVERCAST), first_of(scenes, CITY_NIGHT)
    print("字符越\"满\"越亮（' ' 最暗，'@' 最亮）。每个字符是一个像素，所以分辨率越低，图越小。\n")
    for res in (32, 16, 8, 4):
        f = SIZE // res
        a = luminance(imgs[i].astype(np.float64).reshape(res, f, res, f, 3).mean(axis=(1, 3)))
        b = luminance(imgs[j].astype(np.float64).reshape(res, f, res, f, 3).mean(axis=(1, 3)))
        print(f"  —— {res}×{res}（n_x = {3 * res * res}）——")
        print_panels([ascii_image(a, res), ascii_image(b, res)], [f"阴天（y = {y[i]}）", f"城市夜景（y = {y[j]}）"])
        print()
    print("""观察 Observations：
  · 32×32 和 16×16 时，城市夜景的地面上能看到一个个比周围亮得多的小点（路灯、窗户）；阴天的光是均匀的。
    除了这些小亮点，两张图的天空和地面亮度差不多 —— 它们本来就是从同一个亮度分布里抽出来的（daynight_world.py）。
  · 8×8 时，一个只占 1 个像素的路灯被平均进了 4×4 = 16 个像素的块里，"凸起"只剩原来的 1/16，肉眼已经很难看出来；
    4×4 时两张图几乎一模一样。**细节不是被"压缩"掉的，是被平均掉的 —— 一旦丢了，任何模型都找不回来。**
  · 你可以试试：只看 8×8 那一行，你能分出哪张是夜晚吗？这就是 Kian 说的"用人眼估计需要的分辨率"。""")


# =============================================================================
# 演示 2：人类代理 vs 分辨率
# =============================================================================
def per_scene_accuracy(pred, y, scenes):
    """每种场景各自的准确率：返回长度 5 的 list。"""
    return [float(np.mean(pred[scenes == s] == y[scenes == s])) for s in range(len(SCENES))]


def demo_human(data):
    print_header("演示 2：人类代理 —— 把图缩小到不同分辨率，\"人\"还能分辨吗？（3000 张测试图）")
    imgs, y, scenes = data["test"]
    print("人类代理 = daynight_world.human_proxy：先看天空（又亮又蓝 → 白天；很暗 → 夜晚），")
    print("拿不准时找\"比周围 8 个邻居都亮 0.1 以上\"的孤立小亮点，至少 2 个 → 夜晚，否则 → 白天。\n")
    head = pad("分辨率", 8) + pad("n_x", 7, ">") + pad("准确率", 9, ">") + "".join(pad(s, 12, ">") for s in SCENES)
    print(head)
    accs = {}
    for res in RESOLUTIONS:
        acc, pred = human_proxy_accuracy(imgs, y, res)
        accs[res] = acc
        print(pad(f"{res}×{res}", 8) + f"{3 * res * res:>7}{acc:>9.3f}"
              + "".join(f"{a:>12.3f}" for a in per_scene_accuracy(pred, y, scenes)))
    full = accs[SIZE]
    # "悬崖"：分辨率每降一档（32 → 16 → 8 …），准确率掉了多少；掉得最多的那一步之前的分辨率，就是"人还能分辨"的最小分辨率
    drops = {RESOLUTIONS[k]: accs[RESOLUTIONS[k]] - accs[RESOLUTIONS[k + 1]] for k in range(len(RESOLUTIONS) - 1)}
    enough = max(drops, key=drops.get)
    lower = RESOLUTIONS[RESOLUTIONS.index(enough) + 1]
    print(f"\n这个世界的准确率上限（Bayes 最优）：{BAYES_CEILING:.2f}  —— 黄昏占 10%，它的标签和画面无关，只能猜对一半。")
    print(f"分辨率每降一档，准确率掉得最多的一步：{enough}×{enough} → {lower}×{lower}（{accs[enough]:.3f} → {accs[lower]:.3f}）")
    print(f"""
观察 Observations：
  · 晴天、晴朗的夜晚在**任何**分辨率下都能分对 —— 连 1×1（整张图平均成一个颜色，n_x = 3）都够。
    难的只有两类：黄昏（谁来都只能猜，约 50%）和"阴天 vs 城市夜景"（要靠小亮点区分）。
  · 城市夜景那一列决定了曲线的形状：32×32 时人类代理全对，16×16 时还能认出大部分（小亮点的凸起只剩 1/4），
    8×8 及以下全错（被判成阴天）—— 小亮点被平均掉了。所以按 Kian 的办法，这个任务"人还能分辨"的
    最小分辨率是 {enough}×{enough}（n_x = {3 * enough * enough}），比全分辨率的输入小 {(SIZE // enough) ** 2} 倍；再往下就是悬崖。
  · 全分辨率下人类代理是 {full:.3f}，和上限 {BAYES_CEILING:.2f} 几乎一样：剩下的误差全是黄昏
    （上限是期望值；3000 张图里黄昏的标签是随机抽的，碰巧对得多一点或少一点都正常）。
    这就是第 10 讲的 **Bayes error（不可避免的误差）**：标签本身的不确定性，任何模型都消不掉；
    "人类水平"常被当作它的估计。
  · 注意这个"人"是我们写的规则，阈值 0.1 代表"一眼能看出来"的亮度差。真实项目里你会自己看图，
    或者请几个人在不同分辨率下标一小批图 —— 花一个下午，可能省下几周用错分辨率训练的时间。""")
    return accs


# =============================================================================
# 演示 3：模型 vs 分辨率
# =============================================================================
def fit_model(data, res, hidden, m, seed=7):
    """
    在 m 张训练图上训练一个模型，返回 (训练准确率, 测试准确率, 测试集每张图的预测)。

    【思路】
        1. 把图缩小到 res × res 并拉平：X 的形状 (3·res², m)。
        2. 标准化（只用训练集的均值和标准差）。
        3. 用第 1 讲的 tiny_nn.train（当作黑盒）：Adam，学习率 0.001，mini-batch 128，3000 步，
           weight decay 1e-4，用验证集挑"验证 cost 最低的一步"（keep_best）。
    【参数】
        data   : make_splits() 的返回值
        res    : 分辨率（32、16、8、4、2、1）
        hidden : [] 表示没有隐藏层（= 逻辑回归），[64] 表示一层 64 个单元的隐藏层
        m      : 用训练集的前 m 张
        seed   : 初始化和打乱顺序的种子（所有配置用同一个种子，差别只来自分辨率、模型和数据量）
    【返回】(float, float, (3000,) int 数组)
    【numpy 小知识】y[None, :m] 等价于 y[:m].reshape(1, -1)：None 在最前面加一个长度为 1 的维度，
            把 (m,) 的标签变成 Andrew Ng 约定的 (1, m)。
    """
    tr, va, te = data["train"], data["val"], data["test"]
    X_tr = to_X(tr[0][:m], res)
    sc = Standardizer().fit(X_tr)
    X_tr, X_va, X_te = sc.transform(X_tr), sc.transform(to_X(va[0], res)), sc.transform(to_X(te[0], res))
    Y_tr, Y_va, Y_te = tr[1][None, :m].astype(float), va[1][None, :].astype(float), te[1][None, :].astype(float)
    dims = [X_tr.shape[0]] + hidden + [1]
    params, _, _ = train(X_tr, Y_tr, dims, np.random.default_rng(seed), lr=1e-3, steps=3000, batch_size=128,
                         weight_decay=1e-4, X_val=X_va, Y_val=Y_va, eval_every=100)
    pred_te = (predict(params, X_te) > 0.5).astype(int)[0]
    return accuracy(params, X_tr, Y_tr), accuracy(params, X_te, Y_te), pred_te


def demo_models(data, human):
    print_header(f"演示 3：模型 vs 分辨率（{N_POOL} 张训练图，3000 张测试图）")
    _, y_te, s_te = data["test"]
    print(pad("分辨率", 8) + pad("模型", 22) + pad("参数个数", 10, ">") + pad("每张图乘加", 12, ">")
          + pad("训练准确率", 12, ">") + pad("测试准确率", 12, ">") + pad("城市夜景", 10, ">") + pad("人类代理", 10, ">"))
    results, cities = {}, {}
    for res in RESOLUTIONS:
        n_x = 3 * res * res
        for name, hidden in (("逻辑回归", []), (f"网络 [n_x, {N_HIDDEN}, 1]", [N_HIDDEN])):
            acc_tr, acc_te, pred = fit_model(data, res, hidden, N_POOL)
            results[(res, name[:2], N_POOL)] = acc_te
            dims = [n_x] + hidden + [1]
            macs = sum(dims[l] * dims[l - 1] for l in range(1, len(dims)))     # 前向传播的乘加次数 ≈ 权重个数
            city = float(np.mean(pred[s_te == CITY_NIGHT] == y_te[s_te == CITY_NIGHT]))
            cities[(res, name[:2])] = city
            print(pad(f"{res}×{res}", 8) + pad(name, 22) + f"{count_parameters(dims):>10,}{macs:>12,}"
                  + f"{acc_tr:>12.3f}{acc_te:>12.3f}{city:>10.3f}" + f"{human[res]:>10.3f}")
    lr_accs = [results[(r, "逻辑", N_POOL)] for r in RESOLUTIONS]
    lr_city = [cities[(r, "逻辑")] for r in RESOLUTIONS]
    nn_accs = {r: results[(r, "网络", N_POOL)] for r in RESOLUTIONS}
    best = max(RESOLUTIONS, key=lambda r: nn_accs[r])
    print(f"""
观察 Observations：
  · **逻辑回归在所有分辨率上都差不多**（{min(lr_accs):.3f}–{max(lr_accs):.3f}），和 1×1 的人类代理一个水平；
    城市夜景的准确率在 {min(lr_city):.2f}–{max(lr_city):.2f} 之间乱跳，基本是在猜。
    原因不在分辨率，而在模型：逻辑回归只能对像素做加权求和，而"某处有个小亮点"是一个**位置不固定**的特征 ——
    亮点出现在哪里都行，加权求和只能感受到它对整体亮度那一点点贡献。给它再多像素也没用。
  · **网络（一层隐藏层）明显更好，最好的分辨率是 {best}×{best}（测试准确率 {nn_accs[best]:.3f}）**，而不是最大的 32×32（{nn_accs[32]:.3f}）。
    32×32 的网络有 {count_parameters([3 * 32 * 32, N_HIDDEN, 1]):,} 个参数，训练准确率很高、测试准确率掉下来 —— 典型的过拟合（第 6 讲）：
    输入维度越大，同样 {N_POOL} 张图"摊"到每个参数上的信息就越少。
  · 8×8 的网络在城市夜景上（{cities[(8, "网络")]:.3f}）远好于 8×8 的人类代理（0）：小亮点被平均后只剩 1/16 的凸起，
    人眼（阈值 0.1）看不出，但模型能从很多像素的微弱统计差异里把它"攒"出来。
    **人类代理是一个保守的参考，不是硬性的下限。**
  · 计算量：分辨率每翻一倍，n_x、第一层参数和每张图的乘加次数都变成 4 倍。32×32 → 16×16 → 8×8，
    计算量和模型大小依次只剩 1/4、1/16，准确率反而都比 32×32 高。在手机上本地运行（讲义 §2.5）时，这是实打实的好处。""")
    return results


# =============================================================================
# 演示 4：数据量 × 分辨率
# =============================================================================
def demo_data(data, results):
    print_header(f"演示 4：数据量 × 分辨率 —— 网络 [n_x, {N_HIDDEN}, 1] 的测试准确率")
    ress = [32, 16, 8, 4]
    ms = [500, 2000, N_POOL]
    for res in ress:
        for m in ms[:-1]:
            results[(res, "网络", m)] = fit_model(data, res, [N_HIDDEN], m)[1]
    print(pad("训练图张数", 12) + "".join(pad(f"{r}×{r}", 10, ">") for r in ress) + pad("最好的分辨率", 16, ">"))
    best = {}
    for m in ms:
        row = [results[(r, "网络", m)] for r in ress]
        best[m] = ress[int(np.argmax(row))]
        print(pad(f"{m:,}", 12) + "".join(f"{a:>10.3f}" for a in row) + pad(f"{best[m]}×{best[m]}", 16, ">"))
    gain32 = results[(32, "网络", N_POOL)] - results[(32, "网络", 500)]
    gain4 = results[(4, "网络", N_POOL)] - results[(4, "网络", 500)]
    row500 = [results[(r, "网络", 500)] for r in ress]
    print(f"""
观察 Observations：
  · 数据少（500 张）时，四个分辨率差不多（{min(row500):.3f}–{max(row500):.3f}，"最好的"那一格只是运气）——
    数据太少，网络连"小亮点"这个线索都学不出来，只学会了"看天空亮不亮"这种粗线索；分辨率再高也是浪费。
  · 数据越多，高分辨率越能发挥作用：从 500 到 {N_POOL:,} 张，32×32 涨了 {gain32:.3f}，4×4 只涨了 {gain4:.3f}
    （4×4 的图里小亮点的凸起只剩 1/64，几乎没有信息了，数据再多也学不到多少）。
  · 但即使 {N_POOL:,} 张，32×32 仍然不如 16×16 和 8×8。**"需要多少数据"和"输入多大"是一起决定的**：
    输入越大，要喂饱它的数据越多。五问里"数据"和"输入"紧挨着，原因就在这里。
  · 实际做项目时的顺序：先用"人眼看缩小的图"定一个候选范围（这里是 ≥ 16×16，再往下人就看不出了），
    再在这个范围附近**做实验**比较两三个分辨率（这里发现 8×8 对模型也几乎一样好）。直觉给起点，实验做决定。""")
    return results


def save_figure(human, results, path):
    """
    （可选）把演示 2–4 的准确率画成一张图。没装 matplotlib 时返回 False，不报错。

    【思路】横轴是分辨率（对数刻度：1、2、4、8、16、32 每次翻倍，在对数刻度上等距），纵轴是测试准确率。
            人类代理用灰色虚线，逻辑回归用橙色，网络在三个数据量下用由浅到深的蓝色；95% 上限画一条水平虚线。
    【Python 小知识】try / except ImportError：没装这个库时执行 except 里的代码，而不是让程序崩溃。
    """
    try:
        import matplotlib
        matplotlib.use("Agg")
        import matplotlib.pyplot as plt
    except ImportError:
        return False
    plt.rcParams["font.sans-serif"] = ["PingFang SC", "Heiti SC", "Arial Unicode MS", "Noto Sans CJK SC",
                                       "Microsoft YaHei", "DejaVu Sans"]
    plt.rcParams["axes.unicode_minus"] = False
    fig, ax = plt.subplots(figsize=(8, 5), dpi=150)
    fig.patch.set_facecolor("#fcfcfb")
    ax.set_facecolor("#fcfcfb")
    ax.axhline(BAYES_CEILING, color="#c3c2b7", linestyle=":", linewidth=1.5)
    ax.annotate("上限 0.95（黄昏只能猜）", (1, BAYES_CEILING), xytext=(0, 4), textcoords="offset points",
                fontsize=9, color="#52514e")
    ax.plot(RESOLUTIONS, [human[r] for r in RESOLUTIONS], color="#7b7a75", linestyle="--", marker="o",
            linewidth=2, label="人类代理")
    ax.plot(RESOLUTIONS, [results[(r, "逻辑", N_POOL)] for r in RESOLUTIONS], color="#eb6834", marker="s",
            linewidth=2, label=f"逻辑回归（{N_POOL} 张）")
    for m, color in ((500, "#9ec5f4"), (2000, "#5a96e0"), (N_POOL, "#184f95")):
        rs = [r for r in RESOLUTIONS if (r, "网络", m) in results]
        ax.plot(rs, [results[(r, "网络", m)] for r in rs], color=color, marker="^", linewidth=2,
                label=f"网络（{m} 张）")
    ax.set_xscale("log", base=2)
    ax.set_xticks(RESOLUTIONS)
    ax.set_xticklabels([f"{r}×{r}" for r in RESOLUTIONS])
    ax.set_xlabel("输入分辨率（对数刻度）", color="#52514e")
    ax.set_ylabel("测试准确率", color="#52514e")
    ax.set_title("白天 / 黑夜：分辨率、模型和数据量", color="#0b0b0b", fontsize=11)
    ax.grid(True, color="#e6e5e0", linewidth=0.8)
    for side in ("top", "right"):
        ax.spines[side].set_visible(False)
    ax.legend(frameon=False, fontsize=9, loc="upper left", bbox_to_anchor=(0.0, 0.93))
    fig.tight_layout()
    os.makedirs(os.path.dirname(path), exist_ok=True)
    fig.savefig(path, facecolor=fig.get_facecolor())
    plt.close(fig)
    return True


def main():
    data = make_splits()
    demo_look(data)
    human = demo_human(data)
    results = demo_models(data, human)
    results = demo_data(data, results)
    if save_figure(human, results, os.path.join(HERE, "figures", "lecture05_resolution.png")):
        print("\n（已保存图片 figures/lecture05_resolution.png）")


if __name__ == "__main__":
    main()
