一個專案如果只交出「accuracy=.68」,接手的人還不知道:模型要幫誰做什麼決策、漏掉與錯標有何差別、資料是否偷看了未來,以及什麼條件算完成。
這篇把前面的方法接成一個小型交付。情境是建立訂單時,根據四個當下已知欄位,決定是否標記給人工檢視。完整程式會真的產生資料、切分、訓練、選閾值與做一次凍結測試;輸出也會保留驗證集未達 recall 目標的結果。
資料全部是人工生成,成本也是教學假設。你要帶走的是一套可檢查的專案推進方式,而不是「這個模型已能替真實訂單省錢」的故事。
先寫下決策,別從演算法名稱開始
這個案例的第一份交付,可以先是一張短表:
預測 late,和證明人工檢視能避免 late,是不同問題。本例只生成延遲標籤,沒有介入與未介入的結果對照,因此不能由分類指標推出人工處理的因果效益。
Google 的問題定義教材 區分產品想達到的結果與模型要預測的目標。本例也做這個區分:教學錯誤成本與 recall 用來驗收這段分類流程,沒有實測真實訂單改善率。
若專案需求仍是「用 AI 提升物流」,先把這張表填到能做取捨,再談要換哪個模型。否則模型學得再好,也可能在解錯的問題。
對這個小題目而言,Business Understanding 是先說清「要標記哪種訂單」;Data Understanding/Preparation 要核對欄位與可用時點;Evaluation 則回到錯誤成本與驗收。圖裡有 Deployment,不代表本文已完成部署,它提示的是交付之後仍有另一份工作。
資料合約:每個欄位都要能回答「那時候知道嗎?」
程式用 seed 42 生成 300 列,每列固定四個 features:
| 順序 | 欄位 | 本例資料生成範圍 | 可用時間假設 |
|---|---|---|---|
| 1 | distance | 5 到 100 的人工連續數值 | 建單時已知 |
| 2 | queue | 0 到 20 的整數 | 建單時觀察到的佇列 |
| 3 | promised_days | 1、2、3、4 | 建單時承諾天數 |
| 4 | fragile | 0 或 1 | 建單時已知標記 |
distance 沒有經真實路線量測,也沒有在本例驗證某種實務單位。欄位名只是幫助理解這個合成題目,不能把人工範圍當成業務資料規格。
人工 label 的生成配方是:
logit = −3 + .025×distance + .16×queue
− .7×promised_days + .7×fragile
p = sigmoid(logit)
late = 依固定 RNG、用機率 p 抽出的 0/1
這是作者知道的造資料規則,不會把生成時的 p 或未來 label 當作訓練輸入。模型只拿四個建單欄位和 train labels 擬合。資料有隨機性,所以就算特徵相同,也不保證每筆結果相同。
若改成真實專案,資料合約還要寫清楚 label 何時成熟、是否被取消訂單或回補修正影響、同客戶或相鄰日期是否造成依賴。送達後才知道的實際天數,不能塞回建單時的特徵來證明預測很準。這些真實資料問題,本合成資料沒有模擬完成。
三份資料各有工作,test 不參加選擇
本例先保留 60 筆 test,再把其餘 240 筆切成 180 train、60 validation,兩次都依 label 分層,seed 分別為 17 與 19。分層需要使用 label 來維持切分組成,但沒有把 test 的結果拿來選模型或閾值。
這是隨機合成母體下的教學切分。它不是未來月份的回測,也不是新客戶或新倉庫的 group split;真實部署要依要預測誰、何時預測選合適切分,不能照抄比例就宣稱公平。
三份資料的分工如下:
train180 → fit StandardScaler → fit LogisticRegression
validation60 → 選固定候選閾值、選常數 baseline
凍結模型/閾值/baseline
最後 test60 → 計算預先宣告的驗收結果,不回頭改設定
StandardScaler 與 LogisticRegression 放在同一份 Pipeline,只在 180 train 呼叫 fit。其餘資料使用 train 已學到的尺度與係數。程式輸出 scaler 實際看到 180 筆,並保存三份 IDs、平均、尺度與分類器係數。
scikit-learn 的資料洩漏說明 指出,fit 前處理與模型時不應把 test 混進去;Pipeline 有助於把同一轉換帶到後續資料。本例沒有在 validation 後 refit train+validation,所以最後評估的是同一份已凍結模型,不需要猜 refit 後分數與閾值是否仍對得上。
多折可以用來觀察不同開發切分的結果,仍須依資料相依性選對 splitter,並規劃最後證據如何保留。本例刻意只做三份 holdout,沒有藉這張來源圖聲稱已實跑四折或取得跨折穩定性。
先看簡單決策,再問模型多做了什麼
這個題目有兩個不需要學參數的 baseline:全部不標記,以及全部標記。兩者雖然簡單,卻能把「不用模型會怎樣」說清楚。
validation 的 60 筆中有 15 筆 late。全部不標記就有 15 個 FN,教學成本為 75;全部標記則有 45 個 FP,成本為 45。因此依 validation 最小成本,常數 baseline 凍結成全部標記。
假定錯誤成本 = 5 × FN + 1 × FP
這個 cost 只把兩類錯誤加權,沒有把每次人工檢視的工時、正確標記的處理成本、漏標的實際損害或介入效益算進去。因此就算成本下降,也不是估出真實總營運成本,更不是金錢節省。
成本假設改變,選擇也可能改變。若人工一天只能處理少量訂單,全部標記可能根本不可行;本次沒有給容量約束,所以只能把標記數列出來,不能宣布人工團隊承受得住。
閾值由 validation 選,驗收目標要另外說清楚
LogisticRegression 的 predict_proba 提供分數,標記決策則由閾值決定。這兩層可以分開:官方閾值教材 也把機率估計與採取行動區分,並提醒不應在用來 fit 分類器的相同資料上再調閾值。
先看同一組分數改 threshold 時,指標可能怎麼變:
圖的讀法是先認指標與評估資料,再看哪個 threshold 符合任務;不能把曲線高點直接搬成另一份資料的選擇。本文實際採用的仍是前面定義的 validation 成本,結果如下:
本例沒有呼叫自動 threshold tuning 類別,而是把候選 .1 到 .9 全部列出,在 validation 選最小錯誤成本;同成本取較高閾值。實際表格如下,recall 為約值:
| threshold | FN×5+FP | recall | 標記數/60 |
|---|---|---|---|
| .1 | 39 | .8 | 36 |
| .2 | 34 | .7333 | 25 |
| .3 | 45 | .5333 | 18 |
| .4 | 48 | .4667 | 15 |
| .5 | 56 | .3333 | 11 |
| .6 | 54 | .3333 | 9 |
| .7 | 55 | .2667 | 4 |
| .8 | 65 | .1333 | 2 |
| .9 | 75 | 0 | 0 |
最低成本是 .2,所以固定 threshold=.2。Validation 的 TP=11、FN=4,recall=11/15,約 .7333,低於 .8。不能把這個結果說成驗證階段已證明 recall 達標。
這裡有個值得在專案會議上攤開的設計差別:本程式的「選擇規則」只有最小 validation 成本;「最後教學驗收」才要求 test recall 至少 .8。它不是在 recall≥.8 約束下找最低成本。如果你的需求是開發階段就必須滿足該約束,應在看 test 前改成相應流程並保留新驗證;不能因為後來 test 剛好過了,就把原本選擇規則改寫成已處理約束。
本次保留既定規則,不改到漂亮結果。這讓讀者同時看到:最低成本候選、recall 目標與有限資料的評估結果,可能不會整齊對齊。
凍結後的一次 test:通過哪個條件,沒有證明什麼?
看 test 前,程式已寫入兩個教學條件:成本嚴格低於 validation 選出的常數 baseline,而且 test recall≥.8。模型與 threshold 不再變動,只把它們套到保留的 60 筆。
- 12
- 16
- 29
- 3
- 31
- .8
- 28
- 15
- 45
- 0
- 0
- 45
- 1
- 60
模型的成本是 3×5+16=31,低於 45;recall 是 12/15=.8,這次 acceptance_passed 為 true。Accuracy 約 .6833,precision 為 12/28,約 .4286。
Baseline 的 recall 雖然是 1,卻需要標記全部 60 筆;模型少標了一些,代價是漏掉 3 筆 late。用一個 accuracy 大小無法替這項取捨作決定,必須回到事先定義的目標與成本。
Test recall 剛好等於門檻。這裡只有 15 筆正類,多漏掉一筆就會變成 11/15,約 .7333;本例沒有估計信賴區間,也沒有多次獨立資料驗證。所以 true 的意思是「這一次滿足這段程式預先寫的教學判準」,不是可靠性已被精確估出,更不是可直接上線。
如果沒有通過,就保存原結果與失敗條件,再提出下一個開發假設與獨立評估方案。不能一邊反覆改設定、一邊沿用同一份 test 說自己只驗收一次;一旦它成為選擇依據,就不再是原來那份未參與選擇的證據。
交付時,讓別人能找回每個決定的根據
一份完整的小型專案,可以交得很輕,但不該只剩 notebook 最後一格。這個案例的交付表如下:
- 建單時四欄、標記規則、FN/FP 教學權重
- 真實需求、成本與介入效益
- 300 列生成程式、資料 hash、欄位順序
- 真實資料品質、取得權限與代表性
- train/validation/test IDs、seeds、Pipeline 設定
- 合適的時間/群組外部驗證
- 兩個常數 baseline、九個 validation 閾值全表
- 容量約束、選擇不確定性
- 同模型/同閾值、全部 60 筆 test 預測與結果
- 區間、跨期/跨群體穩定性
- 完整程式、版本、執行命令與成功判準
- API、監控、回滾與實際營運責任
本例 stdout 已保存前五項的主要機械證據,沒有假裝已建 API 或監控。若要走到真實試行,還需要把介入、容量、資料治理、公平/隱私與退出條件逐項補成實際工作,而不是在報告最後寫一句「未來部署」就算交付。
完整可重跑程式:從同一份配方找回結果
以下是完整原碼;不需要真實訂單資料或下載模型。既有實跑環境為 Python 3.10.12、numpy 1.26.3、scikit-learn 1.5.1。若已有對應依賴,在檔案所在目錄執行:
python3 project_acceptance.py > stdout.json
若要另建環境,可先建立 venv,再安裝已列的主要套件版本;這個新環境安裝步驟沒有在本次重新測過,傳遞依賴也不是完整 lock:
python3 -m venv .venv
. .venv/bin/activate
python -m pip install numpy==1.26.3 scikit-learn==1.5.1
python project_acceptance.py > stdout.json
成功時 exit 0,stdout 可解析成 JSON,包含三份 IDs、scaler fit 筆數 180、九個候選結果、threshold .2、全部標記 baseline、完整 test 預測與 true 的教學驗收值。若你的環境數值不同,先核對資料 hash、版本與分割,不要只刪掉不一樣的輸出或調 seed 追這個分數。
"""Original synthetic order-review project: predeclared split, validation choice, one test evaluation."""
import hashlib
import json
import math
import platform
import random
import numpy as np
import sklearn
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
def metrics(labels, predicted):
tp = sum(y == 1 and p == 1 for y, p in zip(labels, predicted))
fp = sum(y == 0 and p == 1 for y, p in zip(labels, predicted))
tn = sum(y == 0 and p == 0 for y, p in zip(labels, predicted))
fn = sum(y == 1 and p == 0 for y, p in zip(labels, predicted))
return {"n": len(labels), "TP": tp, "FP": fp, "TN": tn, "FN": fn,
"cost_FN5_FP1": 5 * fn + fp,
"recall": tp / (tp + fn) if tp + fn else None,
"precision": tp / (tp + fp) if tp + fp else None,
"review_count": tp + fp, "accuracy": (tp + tn) / len(labels)}
def main():
# Declared before looking at the held-out test: synthetic teaching costs, not business estimates.
thresholds = [.1, .2, .3, .4, .5, .6, .7, .8, .9]
minimum_recall = .8
rng = random.Random(42)
records = []
for row_id in range(300):
distance = rng.uniform(5, 100)
queue = rng.randrange(0, 21)
promised_days = rng.choice([1, 2, 3, 4])
fragile = rng.randrange(2)
# All features represent information available at order creation, before the synthetic label.
logit = -3 + .025 * distance + .16 * queue - .7 * promised_days + .7 * fragile
label = int(rng.random() < 1 / (1 + math.exp(-logit)))
records.append({"id": row_id, "features": [distance, queue, promised_days, fragile], "late": label})
x = np.asarray([r["features"] for r in records], dtype=float)
y = np.asarray([r["late"] for r in records], dtype=int)
development, test_ids = train_test_split(np.arange(300), test_size=.2, stratify=y, random_state=17)
train_ids, validation_ids = train_test_split(development, test_size=.25,
stratify=y[development], random_state=19)
assert not set(train_ids) & set(validation_ids) and not set(development) & set(test_ids)
model = Pipeline([("scale", StandardScaler()),
("classifier", LogisticRegression(C=1, max_iter=1000, random_state=0))])
model.fit(x[train_ids], y[train_ids])
validation_probability = model.predict_proba(x[validation_ids])[:, 1]
candidates = [{"threshold": t, **metrics(y[validation_ids].tolist(),
(validation_probability >= t).astype(int).tolist())} for t in thresholds]
choice = min(candidates, key=lambda r: (r["cost_FN5_FP1"], -r["threshold"]))
constant_candidates = [{"constant": c, **metrics(y[validation_ids].tolist(),
[c] * len(validation_ids))} for c in (0, 1)]
baseline_choice = min(constant_candidates, key=lambda r: (r["cost_FN5_FP1"], r["constant"]))
# Freeze the model, threshold and baseline choices. No refit after validation or test.
test_probability = model.predict_proba(x[test_ids])[:, 1]
test_prediction = (test_probability >= choice["threshold"]).astype(int).tolist()
test_result = metrics(y[test_ids].tolist(), test_prediction)
baseline_test = metrics(y[test_ids].tolist(), [baseline_choice["constant"]] * len(test_ids))
accepted = (test_result["cost_FN5_FP1"] < baseline_test["cost_FN5_FP1"]
and test_result["recall"] is not None and test_result["recall"] >= minimum_recall)
result = {"scope": "Original synthetic order-review exercise; no real orders, intervention or API deployment",
"predeclared": {"cost_false_negative": 5, "cost_false_positive": 1,
"threshold_candidates": thresholds, "threshold_tie_rule": "higher threshold if same validation cost",
"minimum_test_recall": minimum_recall,
"acceptance": "test cost strictly below validation-selected constant baseline AND recall>=.8",
"feature_order_at_creation": ["distance", "queue", "promised_days", "fragile"]},
"versions": {"python": platform.python_version(), "numpy": np.__version__, "sklearn": sklearn.__version__},
"data_sha256": hashlib.sha256(json.dumps(records, sort_keys=True, separators=(",", ":")).encode()).hexdigest(),
"split_ids": {"train": train_ids.tolist(), "validation": validation_ids.tolist(), "test": test_ids.tolist()},
"training": {"count": len(train_ids), "scaler_n_samples_seen": int(model.named_steps["scale"].n_samples_seen_),
"scaler_mean": model.named_steps["scale"].mean_.tolist(),
"scaler_scale": model.named_steps["scale"].scale_.tolist(),
"coefficients": model.named_steps["classifier"].coef_[0].tolist(),
"intercept": float(model.named_steps["classifier"].intercept_[0])},
"validation_candidates": candidates, "validation_choice": choice,
"validation_constant_baselines": constant_candidates, "baseline_choice": baseline_choice,
"frozen_test": test_result, "baseline_test": baseline_test,
"test_predictions": [{"id": int(i), "label": int(y[i]), "probability": float(p), "decision": int(a)}
for i, p, a in zip(test_ids, test_probability, test_prediction)],
"acceptance_passed": bool(accepted),
"limits": ["Cost units and .8 recall are teaching assumptions, not observed business economics.",
"A model score does not prove that human review prevents a late delivery or produces savings.",
"Random synthetic split assumes this constructed population; real deployment may require temporal/group splits.",
"One held-out test was used after fixed validation selection; no test-based retuning or refit.",
"No calibration, real-world fairness, privacy compliance, capacity constraint or confidence interval was established.",
"Passing this toy acceptance rule is not authorization for production rollout."]}
print(json.dumps(result, indent=2, allow_nan=False))
if __name__ == "__main__":
main()
完成這個小專案,不在於記住 LogisticRegression 或 .2 這個數字,而在於每個決定都能回答:為何需要它、用哪份資料選的、在什麼條件下算過關,以及下一個接手的人還需要驗證什麼。把未通過與未知也交清楚,才是一個可以繼續往下做的成果。
來源與延伸閱讀:
-
Google:Framing an ML problem:採產品目標、模型輸出與成功指標的區分;本文未引用當前頁面的生成式產品能力作歷史主張。
-
scikit-learn:分類決策閾值:採估計與決策的分工、選擇資料應獨立的原則,沒有執行其 TunedThresholdClassifierCV。本例明寫九候選 validation 手動選擇。
-
scikit-learn:常見錯誤與資料洩漏:先分割、只用 train fit 轉換與模型。查閱的官方 1.5 文件為 1.5.2,本文實跑 1.5.1,未概括宣稱兩版全部相同。
-
圖 1:Alexander Schröder 的 CRISP-DM 作品頁、固定來源版本、CC BY-SA 4.0。本篇只是用六階段關係定位教學工作,沒有把它當規範原文逐條重製。
-
圖 2:Visualizing cross-validation behavior;圖 3:Release Highlights for scikit-learn 1.9。來源為目前官方 1.9.1 文件,圖像依 scikit-learn 的 BSD-3-Clause 聲明使用,完整原文見下方。兩圖未由本站重跑,與本文 1.5.1 原型分開。
圖像署名、改作與完整授權副本:
DS72 image sources and modifications
Figure 1 and cover: Alexander Schröder, CRISP DM Data mining management process (2021). Original work page: https://commons.wikimedia.org/wiki/File:CRISP_DM_Data_mining_management_process.jpg ; source revision: https://commons.wikimedia.org/w/index.php?title=File:CRISP_DM_Data_mining_management_process.jpg&oldid=844742088 . Licensed CC BY-SA 4.0, https://creativecommons.org/licenses/by-sa/4.0/ . Changes: source JPEG converted to RGB PNG without content edits; the complete image is proportionally fitted to 876x900 on a white 1600x900 canvas for the cover. These image adaptations are distributed under CC BY-SA 4.0. No labels, values or arrows were added. This is the uploader's typical illustration of six CRISP-DM phases and feedback relationships, not the normative specification or this project's actual deployment.
Figure 2: The scikit-learn developers, Visualizing cross-validation behavior in scikit-learn, official 1.9.1 documentation, https://scikit-learn.org/1.9/auto_examples/model_selection/plot_cv_indices.html . The official KFold PNG is reproduced byte-for-byte. It depicts four splits over 100 synthetic sample indices and separate class/group rows. KFold in this source does not use those class/group labels to stratify or isolate groups; the generated groups have unequal sizes. This is not this project's 180/60/60 train/validation/test holdout. BSD 3-Clause; full original COPYING follows.
Figure 3: The scikit-learn developers, Release Highlights for scikit-learn 1.9, Computing metrics across thresholds, official 1.9.1 documentation, https://scikit-learn.org/1.9/auto_examples/release_highlights/plot_release_highlights_1_9_0.html . The original synthetic-data Accuracy/F1 plot is reproduced byte-for-byte. The source uses make_classification(weights=[.9,.1],random_state=0), fits LogisticRegression and evaluates threshold metrics on the same X/y. It is an in-sample API illustration, not untouched-test performance or this project's 5FN+FP cost/recall acceptance. The source figure illustrates the threshold concept using a current API; this project itself ran scikit-learn 1.5.1 and did not execute the 1.9 example or claim that API existed before 2023. BSD 3-Clause; full original COPYING follows.
Original scikit-learn 1.9.1 BSD 3-Clause license (verbatim)
BSD 3-Clause License
Copyright (c) 2007-2026 The scikit-learn developers.
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
* Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
留言0
登入後就能留言。
登入