Data drift là hiện tượng phân phối dữ liệu đầu vào của một mô hình đang chạy thật dần khác với phân phối của dữ liệu đã dùng để huấn luyện. Mô hình không báo lỗi, API vẫn trả HTTP 200, độ trễ vẫn đẹp, nhưng chất lượng dự đoán có thể đang đi xuống từng tuần. Bài này dựng một hệ thống giám sát nhỏ cho mô hình dự đoán đơn COD có bị khách từ chối nhận hay không: đo data drift bằng PSI, KS, Wasserstein và chi bình phương; tách riêng concept drift; thiết kế quy tắc cảnh báo ít báo động giả; rồi đẩy số đo ra Prometheus.

Bài nối tiếp MLOps với MLflow (mô hình đã có registry và phiên bản) và Phục vụ mô hình AI bằng FastAPI (dịch vụ đã có giám sát độ trễ). Hai bài đó trả lời câu hỏi "mô hình có chạy không"; bài này trả lời câu hỏi khó hơn: "mô hình còn đúng không". Mọi số liệu là output thật trên Apple M4, 16 GB, Python 3.12 (phiên bản gói ở requirements.txt); mã nguồn ở code/s7-51. Dữ liệu là mô phỏng tự sinh, không phải dữ liệu khách hàng thật.

Vì sao mô hình hỏng mà không ai biết

Khi huấn luyện, ta có nhãn ngay: lịch sử đơn hàng đã biết đơn nào bị từ chối. Khi chạy thật, nhãn đến muộn. Một đơn COD chỉ biết kết quả sau vài ngày giao hàng, một khoản vay chỉ biết có trả nợ sau nhiều tháng. Trong khoảng trễ đó, ta chỉ có đầu vào và dự đoán. Vì vậy giám sát có hai tầng:

  • Tầng sớm, không cần nhãn: theo dõi phân phối từng đặc trưng, phân phối điểm dự đoán và tỷ lệ giá trị thiếu. Đây là nơi data drift được phát hiện.
  • Tầng muộn, cần nhãn: khi nhãn về, tính lại AUC, log loss, độ hiệu chỉnh (calibration). Đây là nơi concept drift lộ ra.

Tầng sớm rẻ nhưng chỉ là tín hiệu gián tiếp: phân phối đổi chưa chắc mô hình tệ đi. Tầng muộn chính xác nhưng đến chậm. Một hệ thống giám sát nghiêm túc dùng cả hai và hiểu rõ mỗi tầng trả lời câu hỏi nào.

Phân biệt data drift, covariate shift, concept drift

Thuật ngữ trong tài liệu khá lẫn lộn, nên bài dùng cách phân biệt theo phân phối xác suất. Gọi X là đặc trưng, y là nhãn:

LoạiCái gì đổiVí dụ đơn CODPhát hiện bằng
Data drift / covariate shiftP(X) đổi, P(y|X) giữ nguyênGiá trị đơn tăng sau mùa khuyến mãi, tỷ trọng tỉnh thành đổiSo sánh phân phối đầu vào, không cần nhãn
Concept driftP(y|X) đổiKhuyến mãi mới kéo theo nhiều đơn "ảo" bị từ chối dù đặc trưng cũ không đổiChỉ số chất lượng khi có nhãn
Label shiftP(y) đổiTỷ lệ hoàn hàng tăng toàn hệ thốngPhân phối điểm dự đoán, rồi nhãn

Tổng quan học thuật về concept drift (Lu và cộng sự, Learning under Concept Drift: A Review) chia bài toán thành phát hiện, hiểu và thích nghi với data drift. Bài này chủ yếu làm phần phát hiện data drift.

Dữ liệu mô phỏng và mô hình tham chiếu

Bài toán: đoán đơn COD bị từ chối. Đặc trưng gồm giá trị đơn (VND), số mặt hàng, thành phố (HCM, HN, DN, Khác), số lần từ chối trước đó và cờ khuyến mãi. Hàm make_batch sinh dữ liệu và cho phép ta bật từng loại data drift có kiểm soát: value_shift nhân giá trị đơn, city_probs đổi tỷ trọng thành phố, concept làm quan hệ giữa đặc trưng và nhãn đổi.

Tập tham chiếu là 20.000 đơn lúc huấn luyện, tỷ lệ từ chối 11,4%. Nguyên tắc quan trọng: tập tham chiếu phải được lưu cùng phiên bản mô hình (ví dụ làm artifact trong MLflow như bài trước), vì "data drift so với cái gì" phải có câu trả lời cố định. Nếu dùng cửa sổ trượt làm tham chiếu, data drift chậm sẽ bị "trôi theo" và không bao giờ bị phát hiện.

Bốn cách đo khoảng cách giữa hai phân phối

Không có chỉ số nào tốt nhất cho mọi trường hợp; bốn chỉ số dưới đây có trong drift_metrics.py.

PSI: chỉ số ổn định phân phối

PSI (Population Stability Index) chia dữ liệu tham chiếu thành các bin (ở đây 10 bin theo phân vị), rồi cộng (q - p) * ln(q / p) trên từng bin, với p và q là tỷ lệ trong tham chiếu và trong dữ liệu mới. PSI vốn phổ biến trong chấm điểm tín dụng. Ngưỡng thường gặp là dưới 0,1 là ổn định, 0,1 đến 0,25 là đáng chú ý, trên 0,25 là lệch lớn; đây là quy ước trong ngành chứ không phải chuẩn thống kê, nên phải hiệu chỉnh theo dữ liệu của bạn. Cần kẹp tỷ lệ tối thiểu (eps) để bin rỗng không làm ln nổ.

def psi_numeric(ref, cur, bins=10, eps=1e-4):
    edges = np.quantile(ref, np.linspace(0, 1, bins + 1))
    edges[0], edges[-1] = -np.inf, np.inf
    p = np.histogram(ref, edges)[0] / len(ref)
    q = np.histogram(cur, edges)[0] / len(cur)
    p, q = np.clip(p, eps, None), np.clip(q, eps, None)
    return float(np.sum((q - p) * np.log(q / p)))

Bin phải tính từ tham chiếu, và hai bin ngoài cùng mở tới vô cực.

KS: kiểm định Kolmogorov–Smirnov hai mẫu

Thống kê KS là khoảng cách lớn nhất giữa hai hàm phân phối tích luỹ thực nghiệm. Theo tài liệu SciPy, giả thuyết không của ks_2samp là hai mẫu cùng một phân phối; với method='auto', nếu cả hai cỡ mẫu nhỏ hơn 10.000 thì thử tính p-value chính xác, ngược lại (chỉ cần một mẫu từ 10.000 trở lên) dùng xấp xỉ tiệm cận; các cặp mẫu trong bài này (ví dụ 20.000 và 5.000) đều dùng xấp xỉ tiệm cận. KS cho cả khoảng cách (0 đến 1) lẫn p-value, nhưng p-value chính là bẫy ở mục sau.

Wasserstein: "phải dịch chuyển bao nhiêu"

Khoảng cách Wasserstein-1 là công sức tối thiểu để biến phân phối này thành phân phối kia, có đơn vị của đặc trưng. Chia cho độ lệch chuẩn của tham chiếu (cột W/σ trong bài), ta được một con số không có đơn vị: "dữ liệu đã dịch bao nhiêu độ lệch chuẩn". Khi đo data drift, chỉ số này không rơi vào kiểu "luôn có ý nghĩa" của p-value khi n lớn, nên hợp để đặt ngưỡng; ở mẫu nhỏ nó vẫn nhiễu (xem n=1.000 ở thí nghiệm B).

Chi bình phương cho biến phân loại

Với thành phố, ta dùng PSI trên các nhóm (cùng công thức) và kiểm định chi bình phương độc lập trên bảng 2 hàng (tham chiếu, hiện tại) nhân số nhóm.

Chạy thật: tám tuần dữ liệu, hai đợt data drift

Mỗi tuần một lô 5.000 đơn so với tham chiếu 20.000 đơn. Từ tuần 4 giá trị đơn tăng 15%, từ tuần 6 tăng 35% và tỷ trọng thành phố "Khác" tăng từ 25% lên 40%. Output thật của demo_drift.py:

tham chiếu: 20000 đơn, tỷ lệ từ chối = 0.114
tuần  PSI_giá     KS     p(KS)    W/σ  PSI_city    chi2 p
   1    0.003  0.012  6.18e-01  0.018     0.001  5.27e-01
   2    0.002  0.017  1.75e-01  0.030     0.001  4.88e-01
   3    0.003  0.011  7.14e-01  0.022     0.000  9.21e-01
   4    0.054  0.093  7.50e-31  0.214     0.001  2.06e-01
   5    0.065  0.112  9.05e-44  0.247     0.000  8.95e-01
   6    0.230  0.205 2.25e-147  0.508     0.111  3.04e-101
   7    0.235  0.203 5.42e-145  0.508     0.096  3.07e-87
   8    0.239  0.207 3.84e-150  0.525     0.113  9.69e-104

Cách đọc: ba tuần đầu mọi chỉ số gần 0. Tuần 4 và 5 giá tăng 15%: p-value KS đã nhỏ tới 1e-31, nhưng PSI mới là 0,054 và 0,065, còn dưới ngưỡng 0,1. Đây là điểm đáng nhớ: p-value cực nhỏ không có nghĩa data drift lớn, nó chỉ nghĩa là ta chắc chắn có khác biệt. Từ tuần 6, PSI giá vọt lên 0,23 và PSI thành phố lên 0,11, trong khi chi bình phương cho thành phố từ tuần 4 đến 5 vẫn không phát hiện gì (p lớn) vì lúc đó thành phố chưa đổi, đúng như dữ liệu được sinh ra.

Bẫy cỡ mẫu: p-value không phải thước đo mức độ

Với dữ liệu đủ lớn, mọi khác biệt dù nhỏ đều có ý nghĩa thống kê. Ngược lại, với mẫu quá nhỏ, data drift thật cũng không đạt p < 0,05. Hai thí nghiệm trong demo_false_alarm.py minh hoạ cả hai mặt:

A) 200 lần so sánh hai lô CÙNG phân phối (n=5.000): tỷ lệ báo drift theo p<0,05
   báo động giả: 12/200
B) Lệch rất nhỏ (giá +1%), cỡ mẫu tăng dần
        n      p(KS)     PSI    W/σ
     1000   5.53e-02  0.0071  0.052
     5000   8.57e-01  0.0006  0.011
    50000   5.79e-02  0.0003  0.016
   200000   2.44e-07  0.0004  0.020

Thí nghiệm A: hai lô cùng phân phối nhưng 12 trên 200 lần (6%) vẫn bị báo data drift, đúng với mức ý nghĩa 5% đã chọn. Kiểm tra 30 đặc trưng mỗi ngày với ngưỡng 0,05 thì trung bình mỗi ngày có một đến hai báo động giả; phải hiệu chỉnh (Bonferroni) hoặc dùng ngưỡng khoảng cách thay cho p-value. Thí nghiệm B: lệch giá chỉ 1% mà ở n = 200.000 p-value xuống 2,4e-07, trong khi PSI vẫn 0,0004, hoàn toàn vô hại. Các dòng n nhỏ dao động là nhiễu lấy mẫu.

Vì thế các công cụ giám sát thực tế chọn phương pháp theo cỡ mẫu. Theo tài liệu Evidently, với tập tham chiếu từ 1.000 dòng trở xuống họ dùng kiểm định (KS cho biến số, chi bình phương cho biến phân loại, ngưỡng p = 0,05); với tập lớn hơn họ chuyển sang khoảng cách (Wasserstein cho biến số, Jensen–Shannon cho biến phân loại, ngưỡng 0,1). Quy tắc rút ra: cỡ mẫu lớn thì đặt ngưỡng trên độ lớn của data drift, không đặt trên p-value.

Concept drift: khi đầu vào không đổi mà mô hình vẫn sai

Data drift không phải lúc nào cũng gây hại, và mô hình có thể hỏng mà đầu vào không lệch. Để thấy rõ, demo_concept.py huấn luyện một HistGradientBoostingClassifier trên tham chiếu, rồi chấm lại trên năm kịch bản khi đã có nhãn:

holdout: AUC=0.659
kịch bản                    PSI_giá  PSI_city    AUC  logloss  tỷ lệ TB dự đoán tỷ lệ thật
ổn định                       0.003     0.002  0.662    0.333             0.114      0.111
covariate: giá +35%           0.259     0.001  0.667    0.353             0.124      0.123
covariate: city đổi           0.003     0.115  0.668    0.349             0.120      0.121
concept: khuyến mãi (0.8)     0.004     0.001  0.581    0.407             0.112      0.140
concept: khuyến mãi (1.5)     0.003     0.001  0.527    0.536             0.116      0.195

Đây là bảng quan trọng nhất của bài. Hai dòng covariate drift có PSI 0,259 và 0,115, vượt ngưỡng cảnh báo, nhưng AUC không giảm (0,667 và 0,668, ngang mức 0,662 của kịch bản ổn định) và tỷ lệ dự đoán trung bình bám sát tỷ lệ thật: mô hình vẫn đúng vì quan hệ P(y|X) không đổi và phần giá, thành phố đã được nó học để tổng quát. Hai dòng concept drift thì ngược lại: PSI các đặc trưng chỉ 0,004 và 0,001, chuông cảnh báo data drift đầu vào im lặng hoàn toàn, nhưng AUC rơi từ 0,662 xuống 0,581 rồi 0,527 (gần đoán mò), và tỷ lệ từ chối thật là 0,195 trong khi mô hình vẫn dự đoán 0,116.

Hệ quả thiết kế: data drift là điều kiện đủ để điều tra, không phải điều kiện đủ để huấn luyện lại; và việc không thấy data drift không chứng minh mô hình khoẻ. Muốn bắt concept drift, phải có nhãn muộn quay về và tính lại chất lượng theo cửa sổ (tuần, tháng), kèm so sánh tỷ lệ dự đoán trung bình với tỷ lệ thật. AUC gốc thấp (0,66) vì dữ liệu mô phỏng nhiều nhiễu; hiệu số giữa các kịch bản mới là điều cần đọc.

Với đặc trưng nhiều chiều (embedding, ảnh, văn bản), so từng chiều vô nghĩa. Nghiên cứu Failing Loudly (Rabanser, Günnemann, Lipton, NeurIPS 2019) báo cáo kiểm định hai mẫu sau khi giảm chiều bằng bộ phân loại đã huấn luyện là phương pháp mạnh nhất trong các kiểu dịch chuyển họ thử; bài này không chạy phương pháp đó.

Thiết kế cảnh báo: ít báo động giả, có hành động rõ

Hệ thống cảnh báo tệ nhất là hệ thống bị tắt tiếng sau hai tuần vì báo quá nhiều. Bốn nguyên tắc áp dụng cho data drift:

  1. Hai mức: warning (PSI từ 0,1) ghi vào dashboard; vượt ngưỡng warning liên tiếp hai kỳ, hoặc chạm critical (từ 0,25), thì gọi người. Mỗi mức gắn một hành động cụ thể.
  2. Yêu cầu bền vững: chỉ gọi người khi vượt ngưỡng liên tiếp ít nhất hai kỳ; một lô lạ đơn lẻ (ngày lễ, một đợt flash sale) không nên đánh thức ai.
  3. Cảnh báo theo đặc trưng quan trọng: xếp hạng đặc trưng bằng mức đóng góp vào mô hình (xem Giải thích mô hình với SHAP); đặc trưng gần như không ảnh hưởng thì data drift của nó chỉ để tham khảo.
  4. Có phương án đối chiếu: mỗi cảnh báo data drift đi kèm chỉ số chất lượng khi có nhãn (mục trước), vì data drift đầu vào một mình hay báo thừa.

alerts.py mô phỏng quy tắc này trên cùng tám tuần, xuất số đo qua prometheus_client. Gauge có nhãn model và feature để một dashboard dùng chung cho nhiều mô hình. Output thật:

tuần feature         PSI mức       liên tiếp  hành động
   6 order_value   0.230 warning           1  
   6 city          0.111 warning           1  
   7 order_value   0.235 warning           2  PAGE: kiểm tra pipeline, xem xét huấn luyện lại
   8 order_value   0.239 warning           3  PAGE: kiểm tra pipeline, xem xét huấn luyện lại
   8 city          0.113 warning           1  
--- /metrics (tuần 8) ---
# HELP model_feature_psi PSI so voi tap tham chieu
# TYPE model_feature_psi gauge
model_feature_psi{feature="order_value",model="cod_refuse_v3"} 0.23908754601725812
model_feature_psi{feature="city",model="cod_refuse_v3"} 0.11279629806330294

Tuần 4 và 5 (PSI 0,054 và 0,065) không phát cảnh báo nào, dù KS đã có p-value cực nhỏ: đó là chủ ý. Tuần 6 giá trị đơn có PSI 0,230, còn dưới ngưỡng critical 0,25 nên chỉ là warning; sang tuần 7 là kỳ liên tiếp thứ hai nên mới "PAGE" (trong tám tuần mô phỏng chưa kỳ nào chạm mức critical 0,25 nên nhánh critical của data drift không xuất hiện trong output). Thành phố có PSI 0,111, 0,096 rồi 0,113: chập chờn quanh ngưỡng nên chuỗi bị reset, đây là lý do hệ thống thật thêm vùng đệm (hysteresis) hoặc trung bình trượt.

Đưa vào Prometheus và Grafana

Nếu hạ tầng đã có Prometheus như ở Giám sát Kubernetes với kube-prometheus và Grafana, bạn không cần công cụ mới. Job theo lịch tính PSI mỗi ngày rồi cập nhật Gauge; tiến trình luôn chạy mở cổng bằng start_http_server theo tài liệu prometheus_client. Job chạy ngắn thì dùng Pushgateway. Quy tắc "hai kỳ liên tiếp" có thể đẩy sang Prometheus bằng mệnh đề for trong alerting rules: cảnh báo ở trạng thái pending cho tới khi điều kiện giữ nguyên suốt khoảng for, và keep_firing_for giữ cảnh báo thêm một lúc để tránh nhấp nháy.

groups:
- name: model-drift
  rules:
  - alert: FeatureDriftSustained
    expr: model_feature_psi{model="cod_refuse_v3"} > 0.1
    for: 2d
    keep_firing_for: 1d
    labels:
      severity: page
    annotations:
      summary: "PSI {{ $labels.feature }} vượt 0,1 liên tục 2 ngày"

Cấu hình YAML này chưa chạy trong bài (máy thử không có Prometheus); hãy kiểm tra bằng promtool check rules trước khi nạp. Bảng điều khiển data drift và cảnh báo xem Grafana Alerting cho pod.

Khi data drift xảy ra thì làm gì

Theo thứ tự rẻ đến đắt: (1) loại trừ lỗi dữ liệu, vì phần lớn data drift thật ra là pipeline hỏng (đổi đơn vị VND sang nghìn đồng, cột mới toàn null, mã thành phố đổi định dạng); (2) xem data drift có hại không, bằng chất lượng khi có nhãn hoặc bằng SHAP để biết đặc trưng đó quan trọng cỡ nào; (3) hiệu chỉnh lại xác suất hoặc đổi ngưỡng quyết định; (4) huấn luyện lại trên dữ liệu gần đây, đăng ký phiên bản mới trong registry (xem bài MLflow) và so với bản cũ bằng các chỉ số ở Đánh giá mô hình phân loại trước khi chuyển hẳn.

Đừng huấn luyện lại tự động chỉ vì có cảnh báo data drift. Bảng ở trên có hai kịch bản đầu vào lệch mà mô hình vẫn đúng; huấn luyện lại ở đó chỉ thêm rủi ro. Dữ liệu gần đây cũng có thể chứa chính hiệu ứng cần tránh, như đơn "ảo" do khuyến mãi, nên xem lại nhãn trước.

Lỗi thường gặp

  • Bin lấy từ dữ liệu mới thay vì tham chiếu: PSI luôn thấp vì bin tự thích nghi. Quên mở hai bin biên tới vô cực cũng làm mất đúng loại data drift nguy hiểm nhất, giá trị ngoài miền huấn luyện.
  • Dùng p-value làm thước đo mức độ ở cỡ mẫu lớn, hoặc kiểm tra nhiều đặc trưng mà không hiệu chỉnh: ngày nào cũng báo động giả.
  • Tham chiếu là cửa sổ trượt: data drift chậm không bao giờ bị phát hiện. Chỉ giám sát đầu vào cũng để lọt concept drift.
  • Ghi log dự đoán kèm dữ liệu cá nhân: giám sát chỉ cần đặc trưng đã ẩn danh hoặc thống kê gộp.

Checklist

  • Lưu tập tham chiếu cùng mỗi phiên bản mô hình; theo dõi phân phối đặc trưng quan trọng, điểm dự đoán và tỷ lệ thiếu.
  • Mẫu nhỏ dùng kiểm định, mẫu lớn dùng khoảng cách (PSI, Wasserstein); cảnh báo hai mức, vượt ngưỡng liên tiếp, có người chịu trách nhiệm.
  • Khi nhãn về, tính chất lượng theo cửa sổ để bắt data drift có hại và concept drift; không huấn luyện lại tự động.

Cài đặt: python3.12 -m venv .venv && .venv/bin/pip install -r requirements.txt, rồi chạy demo_drift.py, demo_false_alarm.py, demo_concept.py và alerts.py trong code/s7-51; các file *_output.txt là output đã dán. Mỗi script chạy dưới một phút, dưới 200 MB RAM.

Bài tiếp theo

AI có trách nhiệm: dữ liệu cá nhân, thiên lệch và Nghị định 13 về bảo vệ dữ liệu. Giám sát cho ta biết mô hình có còn đúng; bài kế tiếp đặt câu hỏi dữ liệu dùng để huấn luyện và giám sát có được phép dùng không, và mô hình có đối xử công bằng giữa các nhóm khách hàng hay không.