跳至主要內容

    完成一個小型資料科學專案:從問題到驗收

    把決策、資料、基準、閾值與凍結測試接起來,交出能被檢查的結果

    sunny 桑尼· · 31 MIN READ · 94 次瀏覽

    完成一個小型資料科學專案:從問題到驗收
    本文目錄

    一個專案如果只交出「accuracy=.68」,接手的人還不知道:模型要幫誰做什麼決策、漏掉與錯標有何差別、資料是否偷看了未來,以及什麼條件算完成。

    這篇把前面的方法接成一個小型交付。情境是建立訂單時,根據四個當下已知欄位,決定是否標記給人工檢視。完整程式會真的產生資料、切分、訓練、選閾值與做一次凍結測試;輸出也會保留驗證集未達 recall 目標的結果。

    資料全部是人工生成,成本也是教學假設。你要帶走的是一套可檢查的專案推進方式,而不是「這個模型已能替真實訂單省錢」的故事。

    先寫下決策,別從演算法名稱開始

    這個案例的第一份交付,可以先是一張短表:

    問題本次明確約定

    決策時點
    訂單建立時
    使用者的動作
    決定是否把該筆訂單標記給人工檢視
    模型任務
    用四欄估計人工 late 標籤為 1 的分數
    輸出如何變決策
    分數大於或等於凍結閾值時標記
    錯誤權重
    FN 每筆 5、FP 每筆 1
    完成證據
    固定資料/切分/配方、驗證選擇與未重選的 test 結果

    預測 late,和證明人工檢視能避免 late,是不同問題。本例只生成延遲標籤,沒有介入與未介入的結果對照,因此不能由分類指標推出人工處理的因果效益。

    Google 的問題定義教材 區分產品想達到的結果與模型要預測的目標。本例也做這個區分:教學錯誤成本與 recall 用來驗收這段分類流程,沒有實測真實訂單改善率。

    若專案需求仍是「用 AI 提升物流」,先把這張表填到能做取捨,再談要換哪個模型。否則模型學得再好,也可能在解錯的問題。

    圖 1|Alexander Schröder 的 CRISP-DM 六階段示意:Business Understanding、Data Understanding、Data Preparation、Modeling、Evaluation、Deployment。箭頭提醒評估可能使人回到問題與資料,不是跑完訓練就直線宣布上線。這是作者自製的典型流程插圖,非規範原件或本文實際部署紀錄。來源:Wikimedia Commons,Alexander Schröder(2021),CC BY-SA 4.0;完整 JPEG 轉 PNG,封面用完整圖等比置於白底;這些圖像改作同以 CC BY-SA 4.0 提供。

    對這個小題目而言,Business Understanding 是先說清「要標記哪種訂單」;Data Understanding/Preparation 要核對欄位與可用時點;Evaluation 則回到錯誤成本與驗收。圖裡有 Deployment,不代表本文已完成部署,它提示的是交付之後仍有另一份工作。

    資料合約:每個欄位都要能回答「那時候知道嗎?」

    程式用 seed 42 生成 300 列,每列固定四個 features:

    順序欄位本例資料生成範圍可用時間假設
    1distance5 到 100 的人工連續數值建單時已知
    2queue0 到 20 的整數建單時觀察到的佇列
    3promised_days1、2、3、4建單時承諾天數
    4fragile0 或 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 後分數與閾值是否仍對得上。

    圖 2|scikit-learn 文件的 KFold 四折示意,橫軸是 100 筆人工樣本索引;每列紅色為該折 testing、藍色為 training,最下方另顯示 class 與 group。普通 KFold 不會因畫出這兩列就自動分層或隔離群組,來源生成的 groups 也不等長。這是另一種評估設計,不是本文 180/60/60 的一次 holdout,圖中 testing 也不能直接當作本文最終 frozen test。來源:scikit-learn developers,官方 1.9.1 文件,BSD-3-Clause;完整原 PNG,未改像素。

    多折可以用來觀察不同開發切分的結果,仍須依資料相依性選對 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 時,指標可能怎麼變:

    圖 3|scikit-learn 1.9 Release Highlights 的人工分類範例,以相同 LogisticRegression 分數計算不同 threshold 的 Accuracy 與 F1。來源在 fit 使用的同一份 X/y 上算這些曲線,是 in-sample API 示範,不是保留測試成績、本文 cost31/45 或 recall 驗收曲線。本文實跑 1.5.1,沒有執行圖中的 1.9 新 API,也不主張它存在於 2023 年以前。來源:scikit-learn developers,BSD-3-Clause;完整原 PNG,未改像素。

    圖的讀法是先認指標與評估資料,再看哪個 threshold 符合任務;不能把曲線高點直接搬成另一份資料的選擇。本文實際採用的仍是前面定義的 validation 成本,結果如下:

    本例沒有呼叫自動 threshold tuning 類別,而是把候選 .1 到 .9 全部列出,在 validation 選最小錯誤成本;同成本取較高閾值。實際表格如下,recall 為約值:

    thresholdFN×5+FPrecall標記數/60
    .139.836
    .234.733325
    .345.533318
    .448.466715
    .556.333311
    .654.33339
    .755.26674
    .865.13332
    .97500

    最低成本是 .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 筆。

    test 決策TPFPTNFN假定成本recall標記數

    Logistic,threshold .2
    TP
    12
    FP
    16
    TN
    29
    FN
    3
    假定成本
    31
    recall
    .8
    標記數
    28
    全部標記 baseline
    TP
    15
    FP
    45
    TN
    0
    FN
    0
    假定成本
    45
    recall
    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 這個數字,而在於每個決定都能回答:為何需要它、用哪份資料選的、在什麼條件下算過關,以及下一個接手的人還需要驗證什麼。把未通過與未知也交清楚,才是一個可以繼續往下做的成果。

    來源與延伸閱讀:

    圖像署名、改作與完整授權副本:

    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

    登入後就能留言。

    登入
    sunny 桑尼
    sunny 桑尼

    AI 資料科學 · 產品經理 · 蒙奇 D 桑尼

    專注於 AI 工具應用與資料科學教育,致力於讓每個人都能用 AI 提升工作效率。曾任職於科技業,現為全職教育創作者。

    讀更多文章 →想自己動手試?領免費 Starter Kit →