{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# 第4章项目：跳远成绩预测器\n",
    "## 异常数据会怎样带偏模型？\n",
    "\n",
    "**本章唯一核心问题：**同一个线性模型、同一份干净测试集、同一个样例学生，只改变训练数据中的一条记录，预测和 MAE 会怎样变化？\n",
    "\n",
    "你不需要从零手写算法。本 Notebook 给出全部代码，并把每段代码和一个可观察的问题放在一起。你要做的是：**先预测 → 运行 → 观察 → 修改一个变量 → 用证据解释。**\n",
    "\n",
    "> 安全边界：数据全部是合成记录；本模型只帮助理解机器学习，不能作为真实学生成绩、身体素质评价或训练建议。\n"
   ],
   "id": "acb9923a"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 学习路线与任务合同\n",
    "\n",
    "```text\n",
    "合成CSV → 数据校验 → 固定训练/测试划分 → 线性模型 → 预测与MAE\n",
    "         ↘ 只污染一条训练记录 → 同一模型再训练 → 控制变量比较\n",
    "```\n",
    "\n",
    "完成本项目后，你应能说清：\n",
    "\n",
    "1. `height`、`weight` 为什么是特征，`jump_distance` 为什么是标签；\n",
    "2. 模型训练得到的三个参数是什么；\n",
    "3. 为什么评价必须使用没有参加训练的干净测试集；\n",
    "4. 一条异常训练记录怎样改变参数、预测和 MAE；\n",
    "5. 数据校验为什么应放在训练之前，以及为什么“异常”不等于“必须删除”。\n",
    "\n",
    "**B档必做（二选一）：**修改异常值大小，或修改跳远数据校验上限。只改一个主变量，并在修改前写预测。\n"
   ],
   "id": "bf3b2609"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# 运行｜环境检查与导入\n",
    "from pathlib import Path\n",
    "import csv\n",
    "import sys\n",
    "import numpy as np\n",
    "import matplotlib.pyplot as plt\n",
    "\n",
    "np.set_printoptions(precision=3, suppress=True)\n",
    "print(\"Python:\", sys.version.split()[0])\n",
    "print(\"NumPy:\", np.__version__)\n",
    "print(\"Matplotlib: imported\")\n",
    "print(\"Core experiment needs no network and no real student data.\")\n"
   ],
   "id": "c9e01ce3"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 1. 找到并读取合成数据\n",
    "\n",
    "Notebook 可能从印刷兼容目录 `teaching_resources/notebooks/` 打开，也可能从本章目录打开。下面的函数只寻找课程提供的相对路径，不依赖教师电脑的绝对路径。\n",
    "\n",
    "我们使用 Python 标准库 `csv` 读取数据，避免把“学会 pandas”变成本章门槛。读入后转换为 NumPy 数组，后续模型公式会更清楚。\n"
   ],
   "id": "7b2a767e"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# 运行｜完整数据读取代码\n",
    "EXPECTED_COLUMNS = [\"record_id\", \"height\", \"weight\", \"jump_distance\", \"split\"]\n",
    "\n",
    "def find_data_file():\n",
    "    candidates = [\n",
    "        Path(\"../samples/ch04_fitness_data.csv\"),\n",
    "        Path(\"../../samples/ch04_fitness_data.csv\"),\n",
    "        Path(\"samples/ch04_fitness_data.csv\"),\n",
    "        Path(\"data/ch04_fitness_data.csv\"),\n",
    "    ]\n",
    "    for candidate in candidates:\n",
    "        if candidate.exists():\n",
    "            return candidate.resolve()\n",
    "    raise FileNotFoundError(\"找不到 ch04_fitness_data.csv；请从课程资源目录打开Notebook。\")\n",
    "\n",
    "DATA_FILE = find_data_file()\n",
    "with DATA_FILE.open(\"r\", encoding=\"utf-8-sig\", newline=\"\") as file:\n",
    "    reader = csv.DictReader(file)\n",
    "    if reader.fieldnames != EXPECTED_COLUMNS:\n",
    "        raise ValueError(f\"字段不匹配：期待 {EXPECTED_COLUMNS}，实际 {reader.fieldnames}\")\n",
    "    rows = list(reader)\n",
    "\n",
    "record_ids = np.array([row[\"record_id\"] for row in rows])\n",
    "features = np.array([[float(row[\"height\"]), float(row[\"weight\"])] for row in rows])\n",
    "labels = np.array([float(row[\"jump_distance\"]) for row in rows])\n",
    "splits = np.array([row[\"split\"] for row in rows])\n",
    "\n",
    "print(\"Data:\", DATA_FILE)\n",
    "print(f\"Rows: {len(rows)} | train: {(splits == 'train').sum()} | test: {(splits == 'test').sum()}\")\n",
    "print(\"First 5 rows:\")\n",
    "for row in rows[:5]:\n",
    "    print(row)\n"
   ],
   "id": "f4cd9ed1"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 思考｜模型看见什么？\n",
    "\n",
    "- 特征（输入）：`height` 和 `weight`。\n",
    "- 标签（要预测的目标）：`jump_distance`。\n",
    "- `record_id` 只帮助追踪记录，不能作为模型特征。\n",
    "- `split` 固定一条记录属于训练集还是测试集，也不能作为特征。\n",
    "\n",
    "先写下你的判断：如果身高、体重相同，但一个人长期训练、另一个人从未练习，本模型能区分吗？为什么？\n"
   ],
   "id": "1a980b5f"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# 运行｜训练前的数据校验\n",
    "VALID_RANGES = {\n",
    "    \"height\": (120.0, 220.0),\n",
    "    \"weight\": (25.0, 150.0),\n",
    "    \"jump_distance\": (50.0, 350.0),\n",
    "}\n",
    "\n",
    "def validate_records(records, valid_ranges=VALID_RANGES):\n",
    "    problems = []\n",
    "    allowed_splits = {\"train\", \"test\"}\n",
    "    for line_number, row in enumerate(records, start=2):\n",
    "        missing = [name for name in EXPECTED_COLUMNS if row.get(name, \"\").strip() == \"\"]\n",
    "        if missing:\n",
    "            problems.append({\"line\": line_number, \"record_id\": row.get(\"record_id\", \"?\"),\n",
    "                             \"problem\": \"missing\", \"detail\": \", \".join(missing)})\n",
    "            continue\n",
    "        if row[\"split\"] not in allowed_splits:\n",
    "            problems.append({\"line\": line_number, \"record_id\": row[\"record_id\"],\n",
    "                             \"problem\": \"invalid split\", \"detail\": row[\"split\"]})\n",
    "        for field, (low, high) in valid_ranges.items():\n",
    "            try:\n",
    "                value = float(row[field])\n",
    "            except ValueError:\n",
    "                problems.append({\"line\": line_number, \"record_id\": row[\"record_id\"],\n",
    "                                 \"problem\": \"not numeric\", \"detail\": field})\n",
    "                continue\n",
    "            if not low <= value <= high:\n",
    "                problems.append({\"line\": line_number, \"record_id\": row[\"record_id\"],\n",
    "                                 \"problem\": \"out of range\",\n",
    "                                 \"detail\": f\"{field}={value}, expected {low}..{high}\"})\n",
    "    return problems\n",
    "\n",
    "clean_problems = validate_records(rows)\n",
    "print(\"Validation problems:\", clean_problems if clean_problems else \"none\")\n",
    "assert not clean_problems, \"课程提供的干净CSV不应包含校验问题。\"\n"
   ],
   "id": "c9b25e6d"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 2. 固定训练集、测试集和样例\n",
    "\n",
    "训练集帮助模型学习参数；测试集只负责最后检查。**测试标签不能参加训练。**\n",
    "\n",
    "固定划分让每次实验可复现。污染实验只向训练集增加一条错误记录，而测试集、样例和模型代码保持不变。这样才能把结果变化归因于训练数据污染。\n"
   ],
   "id": "f4a93521"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# 运行｜固定划分；不要在污染实验中改动\n",
    "train_mask = splits == \"train\"\n",
    "test_mask = splits == \"test\"\n",
    "\n",
    "X_train_clean = features[train_mask]\n",
    "y_train_clean = labels[train_mask]\n",
    "X_test = features[test_mask]\n",
    "y_test = labels[test_mask]\n",
    "\n",
    "SAMPLE_STUDENT = np.array([[175.0, 60.0]])  # height, weight\n",
    "\n",
    "print(\"X_train shape:\", X_train_clean.shape)\n",
    "print(\"X_test shape:\", X_test.shape)\n",
    "print(\"Sample student: height=175 cm, weight=60 kg\")\n",
    "assert len(X_train_clean) == 28 and len(X_test) == 8\n",
    "assert set(record_ids[train_mask]).isdisjoint(set(record_ids[test_mask]))\n"
   ],
   "id": "2440c379"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 3. 运行确定性的线性趋势基线\n",
    "\n",
    "模型形式是：\n",
    "\n",
    "`预测跳远 = 截距 + 身高系数 × 身高 + 体重系数 × 体重`\n",
    "\n",
    "下面的 `fit_linear_model` 使用 NumPy 求最小二乘解。它没有随机初始化、epoch 或需要调节的训练过程，所以同一数据每次会得到相同结果。这里使用最小二乘训练，而评价使用 MAE：训练目标和评价指标不必相同，但必须说清。\n"
   ],
   "id": "bccc45e9"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# 运行｜完整线性模型、预测和MAE代码\n",
    "\n",
    "def add_intercept(X):\n",
    "    \"\"\"在两个特征前增加常数1，让模型能够学习截距。\"\"\"\n",
    "    return np.column_stack([np.ones(len(X)), X])\n",
    "\n",
    "\n",
    "def fit_linear_model(X, y):\n",
    "    \"\"\"返回 [截距, 身高系数, 体重系数]。\"\"\"\n",
    "    design = add_intercept(X)\n",
    "    coefficients, *_ = np.linalg.lstsq(design, y, rcond=None)\n",
    "    return coefficients\n",
    "\n",
    "\n",
    "def predict_linear(coefficients, X):\n",
    "    return add_intercept(X) @ coefficients\n",
    "\n",
    "\n",
    "def mean_absolute_error(y_true, y_pred):\n",
    "    return float(np.mean(np.abs(y_true - y_pred)))\n",
    "\n",
    "\n",
    "def run_experiment(X_train, y_train, name):\n",
    "    coefficients = fit_linear_model(X_train, y_train)\n",
    "    test_predictions = predict_linear(coefficients, X_test)\n",
    "    sample_prediction = float(predict_linear(coefficients, SAMPLE_STUDENT)[0])\n",
    "    return {\n",
    "        \"name\": name,\n",
    "        \"coefficients\": coefficients,\n",
    "        \"test_predictions\": test_predictions,\n",
    "        \"sample_prediction\": sample_prediction,\n",
    "        \"test_mae\": mean_absolute_error(y_test, test_predictions),\n",
    "    }\n",
    "\n",
    "clean_result = run_experiment(X_train_clean, y_train_clean, \"干净训练\")\n",
    "print(\"Model: jump = intercept + height_coef*height + weight_coef*weight\")\n",
    "print(\"Coefficients [intercept, height, weight]:\", clean_result[\"coefficients\"])\n",
    "print(f\"Sample prediction: {clean_result['sample_prediction']:.1f} cm\")\n",
    "print(f\"Clean test MAE: {clean_result['test_mae']:.1f} cm\")\n"
   ],
   "id": "03aba725"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 观察｜不要只抄数字\n",
    "\n",
    "1. 样例预测是否在本章设定的常识范围内？\n",
    "2. MAE 的单位为什么也是 cm？\n",
    "3. 身高和体重系数只是这个合成数据中的统计参数，不表示身高或体重对真实个人的因果作用。\n",
    "\n",
    "把干净结果记在你的对照表中，后面所有变化都与它比较。\n"
   ],
   "id": "6da07a1c"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 4. 控制变量：只污染一条训练记录\n",
    "\n",
    "先预测：把 `jump_distance` 从正常范围改成 1800 cm 后，模型会不会主动报警？样例预测和测试MAE会怎样变化？\n",
    "\n",
    "注意代码只把记录加入 `X_train`、`y_train`。`X_test`、`y_test` 和 `SAMPLE_STUDENT` 没有改变。\n"
   ],
   "id": "b667f241"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# 修改点A｜B档二选一时，可以只改这一行；改前先写预测\n",
    "OUTLIER_JUMP_CM = 1800.0\n",
    "\n",
    "outlier_X = np.array([[175.0, 60.0]])\n",
    "X_train_polluted = np.vstack([X_train_clean, outlier_X])\n",
    "y_train_polluted = np.append(y_train_clean, OUTLIER_JUMP_CM)\n",
    "\n",
    "polluted_result = run_experiment(X_train_polluted, y_train_polluted, \"污染训练\")\n",
    "print(\"Injected training row:\", {\"height\": 175, \"weight\": 60,\n",
    "                                        \"jump_distance\": OUTLIER_JUMP_CM})\n",
    "print(\"Coefficients [intercept, height, weight]:\", polluted_result[\"coefficients\"])\n",
    "print(f\"Sample prediction: {polluted_result['sample_prediction']:.1f} cm\")\n",
    "print(f\"Clean test MAE: {polluted_result['test_mae']:.1f} cm\")\n"
   ],
   "id": "444fc53d"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# 运行｜把控制变量结果放在同一张表里\n",
    "print(f\"{'experiment':<16} {'sample prediction':>20} {'test MAE':>14}\")\n",
    "for result in [clean_result, polluted_result]:\n",
    "    print(f\"{result['name']:<16} {result['sample_prediction']:>17.1f} cm \"\n",
    "          f\"{result['test_mae']:>11.1f} cm\")\n",
    "print(\"\\nChanges caused by the polluted training row:\")\n",
    "print(f\"sample prediction: {polluted_result['sample_prediction'] - clean_result['sample_prediction']:+.1f} cm\")\n",
    "print(f\"test MAE: {polluted_result['test_mae'] - clean_result['test_mae']:+.1f} cm\")\n",
    "\n",
    "assert clean_result[\"test_mae\"] < 10\n",
    "assert polluted_result[\"test_mae\"] > clean_result[\"test_mae\"] * 10\n"
   ],
   "id": "60f926b8"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 5. 用图检查，不让单个指标垄断判断\n",
    "\n",
    "为了能同时显示两个特征，图的横轴使用测试样本的**真实跳远距离**，纵轴使用模型的**预测跳远距离**。灰色对角线表示预测恰好等于真实值；点越偏离对角线，误差越大。\n",
    "\n",
    "这不是“身高—跳远二维趋势线”图，而是预测校准对照图。图题和坐标含义必须说清，不能用一张好看的图制造错误理解。\n"
   ],
   "id": "c680814f"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# 运行｜真实值与预测值对照图\n",
    "fig, ax = plt.subplots(figsize=(8, 5.2))\n",
    "low = min(y_test.min(), clean_result[\"test_predictions\"].min(),\n",
    "          polluted_result[\"test_predictions\"].min()) - 10\n",
    "high = max(y_test.max(), clean_result[\"test_predictions\"].max(),\n",
    "           polluted_result[\"test_predictions\"].max()) + 10\n",
    "ax.plot([low, high], [low, high], color=\"0.65\", linestyle=\"--\", label=\"ideal: prediction = actual\")\n",
    "ax.scatter(y_test, clean_result[\"test_predictions\"], s=70, color=\"#087f7b\", label=\"clean training\")\n",
    "ax.scatter(y_test, polluted_result[\"test_predictions\"], s=70, marker=\"x\", color=\"#c65419\", label=\"polluted training\")\n",
    "ax.set(xlabel=\"Actual jump distance on fixed clean test set (cm)\",\n",
    "       ylabel=\"Predicted jump distance (cm)\",\n",
    "       title=\"Same model and test set; only one training record changed\")\n",
    "ax.grid(alpha=.2)\n",
    "ax.legend()\n",
    "plt.tight_layout()\n",
    "plt.show()\n"
   ],
   "id": "698d042e"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 思考｜用图和数字回答\n",
    "\n",
    "- 橙色叉号离灰色对角线更远，说明什么？\n",
    "- 模型为什么没有自己说“1800 cm 不可能”？\n",
    "- 这能证明线性回归永远很差吗？还是证明数据入口需要检查？\n",
    "\n",
    "请用“条件—证据—结论”的句式写80～150字，不要只写“异常值影响很大”。\n"
   ],
   "id": "84d1523d"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 6. 把数据校验放到训练前\n",
    "\n",
    "下面把人为注入的记录转换为和CSV相同的格式，再使用先前的完整校验函数。校验只负责**标记**，不能替代人工核对来源。\n"
   ],
   "id": "e7a350e2"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# 运行｜验证异常记录会在训练前被标记\n",
    "injected_row = {\n",
    "    \"record_id\": \"INJECTED\",\n",
    "    \"height\": \"175\",\n",
    "    \"weight\": \"60\",\n",
    "    \"jump_distance\": str(OUTLIER_JUMP_CM),\n",
    "    \"split\": \"train\",\n",
    "}\n",
    "validation_report = validate_records(rows + [injected_row])\n",
    "print(\"Problems found:\")\n",
    "for item in validation_report:\n",
    "    print(item)\n",
    "\n",
    "assert any(\"jump_distance\" in item[\"detail\"] for item in validation_report), (\n",
    "    \"当前规则没有标记异常跳远值；请检查 jump_distance 范围。\"\n",
    ")\n"
   ],
   "id": "a4125c6c"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 7. B档必做：二选一，只改一个主变量\n",
    "\n",
    "### 选项A：修改异常值大小\n",
    "\n",
    "只改 `OUTLIER_JUMP_CM`，例如500或900，重新运行从“控制变量”到本单元之前的实验。固定测试集、样例和模型不得改变。\n",
    "\n",
    "填写：旧值 → 新值；修改前预测；修改后样例预测；修改后MAE；是否支持预测。\n",
    "\n",
    "### 选项B：修改数据入口合理范围\n",
    "\n",
    "只改 `VALID_RANGES[\"jump_distance\"]` 的上限，再运行校验单元。说明范围针对什么假设人群，是否仍能拦截1800，以及可能误标哪类真实少见记录。\n",
    "\n",
    "> 不允许同时改异常值、测试集、模型、样例和范围，否则无法判断哪个变量造成变化。\n"
   ],
   "id": "06e3b119"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# 修改点B｜只有选择B时才修改范围；选A的同学保持原值\n",
    "# 例：VALID_RANGES[\"jump_distance\"] = (50.0, 350.0)\n",
    "\n",
    "print(\"Current jump_distance range:\", VALID_RANGES[\"jump_distance\"])\n",
    "print(\"Current outlier value:\", OUTLIER_JUMP_CM)\n",
    "print(\"Remember: write your prediction before changing either one.\")\n"
   ],
   "id": "e72578ac"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 8. 与大语言模型协同，但把判断留在人手中\n",
    "\n",
    "把你自己的两组数字填入下面模板，再发送给Cline、Qwen Web或DeepSeek Web。不要粘贴真实学生数据。\n",
    "\n",
    "```text\n",
    "我正在做线性回归控制变量实验。正常和污染实验使用同一模型、同一干净测试集和同一样例，\n",
    "只在训练集中改变了一条jump_distance记录。正常结果为【填写】，污染结果为【填写】。\n",
    "请先检查这个对照是否公平，再用趋势被异常点拉动的思路解释预测和MAE变化；最后提出3条\n",
    "进入模型前的数据检查规则。不要把预测当成正式成绩。\n",
    "```\n",
    "\n",
    "在下面填写五段留痕。记录方案摘要、文件差异和证据即可，不要求模型展示隐藏的内部思维过程。\n"
   ],
   "id": "ec7aeb89"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### AI协同五段留痕（学生填写）\n",
    "\n",
    "1. **我的原始意图与改前预测：**【填写】\n",
    "2. **模型建议或解释摘要：**【填写平台/模型/日期和摘要】\n",
    "3. **我的选择：**【接受/拒绝/改写了什么，为什么】\n",
    "4. **实际变更与运行证据：**【单元、变量、旧值/新值、数字或图】\n",
    "5. **我的最终判断：**【结果是否支持预测；AI哪里可靠、哪里需要纠正】\n"
   ],
   "id": "682fa4d5"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 9. 可选拓展：Keras `Dense(1)` 表达同一个线性模型\n",
    "\n",
    "这一段只帮助你把“线性函数”与Keras模型层联系起来，不考Keras语法、优化器或epoch。由于优化训练可能随版本产生小幅差异，它不是本章的确定性验收基线。\n",
    "\n",
    "若机器没有TensorFlow，代码会优雅跳过，并显示模型结构的离线说明；基础任务仍完整。为了公平比较，Keras只使用干净训练集，并在同一干净测试集上评价。\n"
   ],
   "id": "01266881"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# 运行（可选）｜TensorFlow存在则训练Dense(1)，不存在则安全跳过\n",
    "try:\n",
    "    import tensorflow as tf\n",
    "except (ImportError, ModuleNotFoundError) as exc:\n",
    "    print(\"Optional Keras comparison skipped:\", type(exc).__name__)\n",
    "    print(\"Offline structure: Input(shape=(2,)) -> Normalization -> Dense(1)\")\n",
    "    print(\"Dense(1) still computes one linear output from the two normalized features.\")\n",
    "else:\n",
    "    tf.keras.utils.set_random_seed(42)\n",
    "    normalizer = tf.keras.layers.Normalization(axis=-1)\n",
    "    normalizer.adapt(X_train_clean)\n",
    "    keras_model = tf.keras.Sequential([\n",
    "        tf.keras.Input(shape=(2,), name=\"height_weight\"),\n",
    "        normalizer,\n",
    "        tf.keras.layers.Dense(1, name=\"jump_prediction\"),\n",
    "    ])\n",
    "    keras_model.compile(optimizer=tf.keras.optimizers.Adam(learning_rate=0.05), loss=\"mae\")\n",
    "    keras_model.fit(X_train_clean, y_train_clean, epochs=300, verbose=0)\n",
    "    keras_predictions = keras_model.predict(X_test, verbose=0).reshape(-1)\n",
    "    keras_sample = float(keras_model.predict(SAMPLE_STUDENT, verbose=0)[0, 0])\n",
    "    print(\"Keras structure: Input(2) -> Normalization -> Dense(1)\")\n",
    "    print(f\"Keras sample prediction: {keras_sample:.1f} cm\")\n",
    "    print(f\"Keras clean test MAE: {mean_absolute_error(y_test, keras_predictions):.1f} cm\")\n"
   ],
   "id": "3d90a4cf"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 10. 验收｜你交付的是证据，不是一次运行截图\n",
    "\n",
    "请确认：\n",
    "\n",
    "- [ ] 我能指出两个特征、一个标签和三个线性参数。\n",
    "- [ ] 正常/污染实验使用同一模型、同一干净测试集和同一样例。\n",
    "- [ ] 我记录了两组样例预测、MAE、参数和一张图。\n",
    "- [ ] 我完成B档二选一，且有改前预测、旧值/新值和结果。\n",
    "- [ ] 我让AI协助解释，但用图和数字复核，必要时明确纠正。\n",
    "- [ ] 我知道校验标记不等于自动删除。\n",
    "- [ ] 我明确写出：本模型不能作为正式成绩或个人评价。\n",
    "\n",
    "**最后结论（100～200字，学生填写）：**【填写】\n",
    "\n",
    "进一步思考：如果测试集也含同一条1800 cm标签，MAE增大还只说明模型被训练数据带偏吗？\n"
   ],
   "id": "8597bd31"
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "name": "python",
   "version": "3.10"
  },
  "aibp": {
   "book": "higher-vocational",
   "chapter": 4,
   "resource_version": "0.1.0-sample",
   "core_offline": true
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}