{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "intro-1",
   "metadata": {},
   "source": [
    "# 第 7 題｜傳統機器學習與模型評估：保單續保流失風險\n",
    "\n",
    "本 Notebook 使用 Python 標準函式庫與固定亂數種子建立純合成資料。資料不是真實保單、個資或實際續保紀錄；不連網、不讀取外部檔案，也不呼叫服務。"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "intro-2",
   "metadata": {},
   "source": [
    "## 合成資料與時間切分\n",
    "\n",
    "共產生 300 筆資料：月份 0–9、每月 30 筆。保費調整依月份使用平均數 0.05／0.10／0.16、標準差 0.045 的常態抽樣並裁切至 [0, 0.35]；客服聯絡次數 0–4 的抽樣權重為 [35, 30, 20, 10, 5]，年資均勻抽樣於 [0.5, 15]。標籤由指定 logit 的 sigmoid 機率抽樣。\n",
    "\n",
    "依時間原順序切分 train=前 180 筆、valid=接續 60 筆、test=最後 60 筆，不洗牌。標準化平均數與母體標準差只用訓練集估計；遇到零標準差則使用 1。"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "intro-3",
   "metadata": {},
   "source": [
    "## 邏輯斯迴歸、閾值與評估\n",
    "\n",
    "模型使用全批次梯度下降 1200 步、learning rate=0.12、L2=0.002（不懲罰截距）。只根據驗證集 F1 從 0.10 至 0.90、每次增加 0.05 選閾值；同分時採較高閾值。測試集只用已選定閾值評估。Baseline 機率是訓練集正例率，分類閾值固定為 0.50。以下程式碼與可執行套件共用相同實作。"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "intro-4",
   "metadata": {},
   "source": [
    "## 結果解讀、安全與限制\n",
    "\n",
    "測試資料位於較晚月份，合成保費調整分布隨月份改變，提醒我們需監控資料／概念漂移，不能把一次切分的表現外推至真實業務。誤報會消耗客服容量；閾值應另考量服務量能、聯繫成本與漏報代價。Brier score 單獨不足以證明校準良好，仍需按時間／群體檢查可靠度圖、校準及外部驗證。\n",
    "\n",
    "模型只能作為人工客服排序的教學示範，不得用於保費、核保、理賠或其他個人權益決策，也不得宣稱合成結果代表真實續保效益。若未來處理真實資料，須先取得授權並採資料最小化、去識別、存取控制及安全保存措施；本 Notebook 不包含也不使用真實保單或個資。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 1,
   "id": "code-1",
   "metadata": {},
   "outputs": [],
   "source": [
    "#!/usr/bin/env python3\n",
    "\"\"\"以標準函式庫示範保單續保流失風險的合成資料分類流程。\"\"\"\n",
    "\n",
    "import math\n",
    "import random\n",
    "\n",
    "SEED = 20261007\n",
    "FEATURES = (\"premium_change\", \"contacts_90d\", \"tenure_years\")\n",
    "THRESHOLDS = [i / 100 for i in range(10, 91, 5)]\n",
    "TRAIN_STEPS = 1200\n",
    "LEARNING_RATE = 0.12\n",
    "L2 = 0.002\n",
    "\n",
    "\n",
    "def sigmoid(value):\n",
    "    \"\"\"數值穩定的 sigmoid。\"\"\"\n",
    "    if value >= 0:\n",
    "        exp_value = math.exp(-value)\n",
    "        return 1.0 / (1.0 + exp_value)\n",
    "    exp_value = math.exp(value)\n",
    "    return exp_value / (1.0 + exp_value)\n",
    "\n",
    "\n",
    "def generate_rows(seed=SEED):\n",
    "    \"\"\"產生 10 個月份、每月 30 筆的純合成資料。\"\"\"\n",
    "    rng = random.Random(seed)\n",
    "    rows = []\n",
    "    contact_weights = (35, 30, 20, 10, 5)\n",
    "\n",
    "    for month in range(10):\n",
    "        if month < 6:\n",
    "            premium_mean = 0.05\n",
    "        elif month < 8:\n",
    "            premium_mean = 0.10\n",
    "        else:\n",
    "            premium_mean = 0.16\n",
    "\n",
    "        for _ in range(30):\n",
    "            premium_change = min(\n",
    "                0.35, max(0.0, rng.gauss(premium_mean, 0.045))\n",
    "            )\n",
    "            contacts_90d = rng.choices(range(5), weights=contact_weights, k=1)[0]\n",
    "            tenure_years = rng.uniform(0.5, 15.0)\n",
    "            logit = (\n",
    "                -2.0\n",
    "                + 6.0 * premium_change\n",
    "                + 0.75 * contacts_90d\n",
    "                - 0.12 * tenure_years\n",
    "            )\n",
    "            churn_probability = sigmoid(logit)\n",
    "            churn = int(rng.random() < churn_probability)\n",
    "            rows.append(\n",
    "                {\n",
    "                    \"month\": month,\n",
    "                    \"premium_change\": premium_change,\n",
    "                    \"contacts_90d\": contacts_90d,\n",
    "                    \"tenure_years\": tenure_years,\n",
    "                    \"churn\": churn,\n",
    "                }\n",
    "            )\n",
    "\n",
    "    return rows\n",
    "\n",
    "\n",
    "def split_rows(rows):\n",
    "    \"\"\"依時間原順序切分訓練、驗證與測試資料，不洗牌。\"\"\"\n",
    "    return rows[:180], rows[180:240], rows[240:]\n",
    "\n",
    "\n",
    "def fit_standardizer(train_rows):\n",
    "    \"\"\"僅用傳入的訓練列估計母體平均數與標準差。\"\"\"\n",
    "    if not train_rows:\n",
    "        raise ValueError(\"訓練資料不可為空\")\n",
    "\n",
    "    means = []\n",
    "    stds = []\n",
    "    for feature in FEATURES:\n",
    "        values = [float(row[feature]) for row in train_rows]\n",
    "        mean = sum(values) / len(values)\n",
    "        variance = sum((value - mean) ** 2 for value in values) / len(values)\n",
    "        std = math.sqrt(variance)\n",
    "        means.append(mean)\n",
    "        stds.append(std if std != 0.0 else 1.0)\n",
    "    return means, stds\n",
    "\n",
    "\n",
    "def transform_rows(rows, means, stds):\n",
    "    \"\"\"用訓練集統計量標準化特徵，保留其餘欄位供追蹤與評估。\"\"\"\n",
    "    if len(means) != len(FEATURES) or len(stds) != len(FEATURES):\n",
    "        raise ValueError(\"標準化統計量必須對應三個特徵\")\n",
    "\n",
    "    transformed = []\n",
    "    for row in rows:\n",
    "        scaled = dict(row)\n",
    "        for index, feature in enumerate(FEATURES):\n",
    "            scaled[feature] = (float(row[feature]) - means[index]) / stds[index]\n",
    "        transformed.append(scaled)\n",
    "    return transformed\n",
    "\n",
    "\n",
    "def fit_logistic_regression(train_rows, steps=TRAIN_STEPS,\n",
    "                            learning_rate=LEARNING_RATE, l2=L2):\n",
    "    \"\"\"以全批次梯度下降訓練邏輯斯迴歸；L2 不懲罰截距。\"\"\"\n",
    "    if not train_rows:\n",
    "        raise ValueError(\"訓練資料不可為空\")\n",
    "    if steps <= 0 or learning_rate <= 0 or l2 < 0:\n",
    "        raise ValueError(\"steps 與 learning_rate 必須為正，l2 不可為負\")\n",
    "\n",
    "    weights = [0.0] * len(FEATURES)\n",
    "    bias = 0.0\n",
    "    row_count = len(train_rows)\n",
    "\n",
    "    for _ in range(steps):\n",
    "        bias_gradient = 0.0\n",
    "        weight_gradients = [0.0] * len(FEATURES)\n",
    "        for row in train_rows:\n",
    "            score = bias + sum(\n",
    "                weights[index] * float(row[feature])\n",
    "                for index, feature in enumerate(FEATURES)\n",
    "            )\n",
    "            error = sigmoid(score) - int(row[\"churn\"])\n",
    "            bias_gradient += error\n",
    "            for index, feature in enumerate(FEATURES):\n",
    "                weight_gradients[index] += error * float(row[feature])\n",
    "\n",
    "        bias -= learning_rate * bias_gradient / row_count\n",
    "        for index in range(len(FEATURES)):\n",
    "            gradient = weight_gradients[index] / row_count + l2 * weights[index]\n",
    "            weights[index] -= learning_rate * gradient\n",
    "\n",
    "    return bias, weights\n",
    "\n",
    "\n",
    "def predict_probabilities(rows, bias, weights):\n",
    "    \"\"\"對標準化後的資料計算流失機率。\"\"\"\n",
    "    if len(weights) != len(FEATURES):\n",
    "        raise ValueError(\"模型權重必須對應三個特徵\")\n",
    "    return [\n",
    "        sigmoid(\n",
    "            bias\n",
    "            + sum(\n",
    "                weights[index] * float(row[feature])\n",
    "                for index, feature in enumerate(FEATURES)\n",
    "            )\n",
    "        )\n",
    "        for row in rows\n",
    "    ]\n",
    "\n",
    "\n",
    "def evaluate(y_true, probabilities, threshold):\n",
    "    \"\"\"計算混淆矩陣（TP/FP/FN/TN）、分類指標與 Brier score。\"\"\"\n",
    "    if len(y_true) != len(probabilities) or not y_true:\n",
    "        raise ValueError(\"標籤與機率必須等長且不可為空\")\n",
    "    if not 0.0 <= threshold <= 1.0:\n",
    "        raise ValueError(\"分類閾值必須介於 0 與 1\")\n",
    "\n",
    "    tp = fp = fn = tn = 0\n",
    "    for label, probability in zip(y_true, probabilities):\n",
    "        if not 0.0 <= probability <= 1.0:\n",
    "            raise ValueError(\"預測機率必須介於 0 與 1\")\n",
    "        predicted = probability >= threshold\n",
    "        actual = int(label) == 1\n",
    "        if predicted and actual:\n",
    "            tp += 1\n",
    "        elif predicted:\n",
    "            fp += 1\n",
    "        elif actual:\n",
    "            fn += 1\n",
    "        else:\n",
    "            tn += 1\n",
    "\n",
    "    precision = tp / (tp + fp) if tp + fp else 0.0\n",
    "    recall = tp / (tp + fn) if tp + fn else 0.0\n",
    "    f1 = (\n",
    "        2.0 * precision * recall / (precision + recall)\n",
    "        if precision + recall\n",
    "        else 0.0\n",
    "    )\n",
    "    brier = sum(\n",
    "        (float(probability) - int(label)) ** 2\n",
    "        for label, probability in zip(y_true, probabilities)\n",
    "    ) / len(y_true)\n",
    "    return {\n",
    "        \"tp\": tp,\n",
    "        \"fp\": fp,\n",
    "        \"fn\": fn,\n",
    "        \"tn\": tn,\n",
    "        \"precision\": precision,\n",
    "        \"recall\": recall,\n",
    "        \"f1\": f1,\n",
    "        \"brier\": brier,\n",
    "    }\n",
    "\n",
    "\n",
    "def choose_threshold(y_true, probabilities, thresholds=THRESHOLDS):\n",
    "    \"\"\"依驗證集 F1 選閾值；F1 相同時採較高閾值。\"\"\"\n",
    "    if not thresholds:\n",
    "        raise ValueError(\"閾值候選不可為空\")\n",
    "    candidates = [\n",
    "        (float(threshold), evaluate(y_true, probabilities, float(threshold)))\n",
    "        for threshold in thresholds\n",
    "    ]\n",
    "    return max(candidates, key=lambda candidate: (candidate[1][\"f1\"], candidate[0]))\n",
    "\n",
    "\n",
    "def run_analysis():\n",
    "    \"\"\"執行完整合成資料流程並回傳可列印結果。\"\"\"\n",
    "    rows = generate_rows()\n",
    "    train_rows, valid_rows, test_rows = split_rows(rows)\n",
    "    means, stds = fit_standardizer(train_rows)\n",
    "    scaled_train = transform_rows(train_rows, means, stds)\n",
    "    scaled_valid = transform_rows(valid_rows, means, stds)\n",
    "    scaled_test = transform_rows(test_rows, means, stds)\n",
    "\n",
    "    bias, weights = fit_logistic_regression(scaled_train)\n",
    "    valid_y = [row[\"churn\"] for row in valid_rows]\n",
    "    valid_probabilities = predict_probabilities(scaled_valid, bias, weights)\n",
    "    threshold, valid_metrics = choose_threshold(valid_y, valid_probabilities)\n",
    "\n",
    "    test_y = [row[\"churn\"] for row in test_rows]\n",
    "    test_probabilities = predict_probabilities(scaled_test, bias, weights)\n",
    "    model_metrics = evaluate(test_y, test_probabilities, threshold)\n",
    "\n",
    "    baseline_probability = sum(row[\"churn\"] for row in train_rows) / len(train_rows)\n",
    "    baseline_metrics = evaluate(\n",
    "        test_y,\n",
    "        [baseline_probability] * len(test_y),\n",
    "        0.5,\n",
    "    )\n",
    "    return {\n",
    "        \"rows\": rows,\n",
    "        \"train_rows\": train_rows,\n",
    "        \"valid_rows\": valid_rows,\n",
    "        \"test_rows\": test_rows,\n",
    "        \"means\": means,\n",
    "        \"stds\": stds,\n",
    "        \"weights\": weights,\n",
    "        \"bias\": bias,\n",
    "        \"threshold\": threshold,\n",
    "        \"valid_metrics\": valid_metrics,\n",
    "        \"baseline_probability\": baseline_probability,\n",
    "        \"baseline_metrics\": baseline_metrics,\n",
    "        \"model_metrics\": model_metrics,\n",
    "        \"test_average_premium_change\": sum(\n",
    "            row[\"premium_change\"] for row in test_rows\n",
    "        ) / len(test_rows),\n",
    "    }\n",
    "\n",
    "\n",
    "def print_metrics(name, result):\n",
    "    print(\n",
    "        f\"{name}: TP={result['tp']} FP={result['fp']} \"\n",
    "        f\"FN={result['fn']} TN={result['tn']} | \"\n",
    "        f\"precision={result['precision']:.3f} \"\n",
    "        f\"recall={result['recall']:.3f} F1={result['f1']:.3f} \"\n",
    "        f\"Brier={result['brier']:.3f}\"\n",
    "    )\n",
    "\n",
    "\n",
    "def print_report(result):\n",
    "    train_rows = result[\"train_rows\"]\n",
    "    valid_rows = result[\"valid_rows\"]\n",
    "    test_rows = result[\"test_rows\"]\n",
    "    print(\n",
    "        \"合成虛構資料：seed=20261007，資料筆數=\"\n",
    "        f\"{len(result['rows'])}，train/valid/test=\"\n",
    "        f\"{len(train_rows)}/{len(valid_rows)}/{len(test_rows)}\"\n",
    "    )\n",
    "    print(\n",
    "        \"正例數：train=\"\n",
    "        f\"{sum(row['churn'] for row in train_rows)}，valid=\"\n",
    "        f\"{sum(row['churn'] for row in valid_rows)}，test=\"\n",
    "        f\"{sum(row['churn'] for row in test_rows)}\"\n",
    "    )\n",
    "    print(\n",
    "        \"test 平均 premium_change=\"\n",
    "        f\"{result['test_average_premium_change']:.3f}；\"\n",
    "        f\"validation 最佳 F1 閾值={result['threshold']:.2f} \"\n",
    "        f\"(validation F1={result['valid_metrics']['f1']:.3f})\"\n",
    "    )\n",
    "    print(\n",
    "        \"Baseline（訓練集正例率=\"\n",
    "        f\"{result['baseline_probability']:.3f}，分類閾值=0.50）\"\n",
    "    )\n",
    "    print_metrics(\"  測試集\", result[\"baseline_metrics\"])\n",
    "    print(f\"Logistic regression（分類閾值={result['threshold']:.2f}）\")\n",
    "    print_metrics(\"  測試集\", result[\"model_metrics\"])\n",
    "    print(\"注意：以上只展示合成資料的示範結果，不代表真實續保效益。\")\n",
    "\n",
    "\n",
    "def main():\n",
    "    print_report(run_analysis())\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 2,
   "id": "code-2",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "合成虛構資料：seed=20261007，資料筆數=300，train/valid/test=180/60/60\n",
      "正例數：train=32，valid=18，test=12\n",
      "test 平均 premium_change=0.165；validation 最佳 F1 閾值=0.25 (validation F1=0.522)\n",
      "Baseline（訓練集正例率=0.178，分類閾值=0.50）\n",
      "  測試集: TP=0 FP=0 FN=12 TN=48 | precision=0.000 recall=0.000 F1=0.000 Brier=0.160\n",
      "Logistic regression（分類閾值=0.25）\n",
      "  測試集: TP=12 FP=32 FN=0 TN=16 | precision=0.273 recall=1.000 F1=0.429 Brier=0.157\n",
      "注意：以上只展示合成資料的示範結果，不代表真實續保效益。\n"
     ]
    }
   ],
   "source": [
    "results = run_analysis()\n",
    "print_report(results)"
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "file_extension": ".py",
   "mimetype": "text/x-python",
   "name": "python",
   "version": "3.13.5"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
