Mã nguồn thí nghiệm
Mọi hình và mọi con số thực nghiệm trong giáo trình đều do các script dưới đây sinh ra. Chúng được chép nguyên vẹn vào site nên tải về là chạy được ngay.
Cách chạy lại toàn bộ#
Ba script dưới đây sinh ra toàn bộ 14 hình, mọi con số thực nghiệm trong bài và số liệu dùng trong lời giải bài tập. Chúng đặt hạt giống cố định nên chạy lại cho kết quả y hệt.
pip install numpy scipy matplotlib scikit-learn
python code/mlops/experiments.py # Hình 6, 7, 9–14 và số liệu trong bài, khoảng một phút
python code/mlops/bai_tap.py # số liệu cho lời giải bài tập, khoảng một phút rưỡi
python code/mlops/fig_diagrams.py # Hình 1–5 và Hình 8
Môi trường đã dùng để sinh số liệu: Python 3.13, NumPy 2.3, SciPy 1.16, scikit-learn 1.7, matplotlib 3.10.
Về phạm vi: số liệu thực nghiệm đến từ dữ liệu mô phỏng, mỗi thí nghiệm được thiết kế để cô lập một cơ chế. Chúng cho thấy cơ chế đó tồn tại và có độ lớn đáng kể, không dùng để suy ra con số cho một hệ thống cụ thể. Muốn có con số cho một hệ thống thật, cần chạy cùng thí nghiệm trên dữ liệu của hệ thống đó; các bài tập ở Chương 14 hướng dẫn cách làm.
Từng script#
code/mlops/experiments.py
viết thêm cho repo nàyTải vềSinh Hình 6, 7 và 9–14 cùng mọi số liệu thực nghiệm trong bài: đánh giá theo lát cắt, phần đuôi độ trễ khi toả nhánh, cỡ mẫu A/B test, ba loại dịch chuyển phân phối, PSI khi không có dịch chuyển, so sánh quy tắc PSI với kiểm định KS, mức suy giảm của mô hình theo nhịp huấn luyện lại, và vòng phản hồi thoái hoá.
Xem mã nguồn
"""Thí nghiệm sinh số liệu và Hình 6, 7, 9–14 của giáo trình MLOps.
Mọi con số thực nghiệm trong tài liệu đều in ra từ script này. Hạt giống cố định
nên chạy lại cho kết quả y hệt.
python code/mlops/experiments.py
"""
import numpy as np
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from scipy import stats
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import GradientBoostingClassifier
OUT = "figs/"
SEED = 2026
rng = np.random.default_rng(SEED)
plt.rcParams.update({
"figure.dpi": 150, "font.size": 9, "axes.grid": True,
"grid.alpha": .25, "axes.spines.top": False, "axes.spines.right": False,
})
C_MAIN, C_WARN, C_BAD, C_DIM = "#1f6f68", "#b8860b", "#b0413e", "#8a857c"
def head(t):
print("\n" + "=" * 72 + "\n" + t + "\n" + "=" * 72)
# =====================================================================
# (A) Hình 7 — Đuôi độ trễ khi một yêu cầu toả ra nhiều dịch vụ
# =====================================================================
head("(A) Ngân sach do tre: p99 cua max-of-k (tail at scale)")
P50, P99 = 20.0, 100.0 # ms, do tre mot dich vu
sigma = np.log(P99 / P50) / stats.norm.ppf(0.99)
mu = np.log(P50)
print(f"lognormal: median={P50} ms, p99={P99} ms -> mu={mu:.4f}, sigma={sigma:.4f}")
def q_of_max(q, k):
"""Phan vi q cua max cua k lan goi doc lap."""
return np.exp(mu + sigma * stats.norm.ppf(q ** (1.0 / k)))
ks = np.array([1, 2, 5, 10, 20, 50, 100])
p99_max = np.array([q_of_max(0.99, k) for k in ks])
slow_frac = 1 - 0.99 ** ks # ti le yeu cau cham it nhat 1 nhanh
print(f"{'k':>5} {'p99 cua max (ms)':>18} {'% yeu cau co >=1 nhanh vuot p99':>34}")
for k, p, s in zip(ks, p99_max, slow_frac):
print(f"{k:5d} {p:18.1f} {s*100:33.1f}%")
A = dict(ks=ks, p99=p99_max, slow=slow_frac, sigma=sigma, mu=mu)
fig, ax = plt.subplots(1, 2, figsize=(8.4, 3.0))
ax[0].plot(ks, p99_max, "-o", ms=4, color=C_MAIN)
ax[0].axhline(P99, ls="--", lw=1.1, color=C_DIM)
ax[0].annotate(f"p99 một dịch vụ = {P99:.0f} ms", (1, P99), xytext=(4, -14),
textcoords="offset points", fontsize=8, color=C_DIM)
ax[0].set_xscale("log"); ax[0].set_xlabel("số nhánh k gọi song song")
ax[0].set_ylabel("p99 của thời gian trả lời (ms)")
ax[0].set_title("p99 của thời gian trả lời theo số nhánh")
ax[1].plot(ks, slow_frac * 100, "-o", ms=4, color=C_BAD)
ax[1].set_xscale("log"); ax[1].set_xlabel("số nhánh k gọi song song")
ax[1].set_ylabel("% yêu cầu chạm ít nhất một nhánh chậm")
ax[1].set_title("Xác suất chạm đuôi = $1-0{,}99^k$")
fig.tight_layout(); fig.savefig(OUT + "mlops07_tail.png"); plt.close(fig)
# =====================================================================
# (B) Hình 6 — Chỉ số gộp tăng trong khi một lát cắt tụt
# =====================================================================
head("(B) Danh gia theo lat cat: chi so gop che mat su that")
def make_slices(n=8000, seed=1):
"""Nhóm B là người dùng mới: đặc trưng x2 (lịch sử mua hàng) chỉ có nghĩa với nhóm A."""
r = np.random.default_rng(seed)
is_b = r.random(n) < 0.15 # 15% la nguoi dung moi
x1 = r.normal(0, 1, n) # dac trung ai cung co
y = (r.random(n) < 1 / (1 + np.exp(-1.1 * x1))).astype(int)
# x2 manh nhung chi co tin hieu voi nhom A; voi nhom B no la nhieu thuan tuy
x2 = np.where(is_b, r.normal(0, 1, n), 2.2 * (y - 0.5) + r.normal(0, 0.7, n))
return np.c_[x1, x2], y, is_b.astype(int)
Xtr, ytr, gtr = make_slices(seed=1)
Xte, yte, gte = make_slices(seed=2)
v1 = LogisticRegression().fit(Xtr[:, :1], ytr) # chi dung x1
v2 = LogisticRegression().fit(Xtr, ytr) # them x2
ok1 = v1.predict(Xte[:, :1]) == yte
ok2 = v2.predict(Xte) == yte
rows = []
for name, mask in [("Tổng thể", np.ones(len(yte), bool)),
("Nhóm A (85%)", gte == 0), ("Nhóm B (15%)", gte == 1)]:
rows.append((name, ok1[mask].mean(), ok2[mask].mean()))
print("v2 = v1 + đặc trưng x2, thứ chỉ có tín hiệu với nhóm A")
print(f"{'Lát cắt':<14}{'v1':>9}{'v2':>9}{'thay đổi':>11}")
for n_, a, b in rows:
print(f"{n_:<14}{a*100:8.2f}%{b*100:8.2f}%{(b-a)*100:+10.2f} đ")
print(f"trọng số v2 học được: x1={v2.coef_[0,0]:.2f}, x2={v2.coef_[0,1]:.2f}")
B = rows
fig, ax = plt.subplots(figsize=(5.2, 3.0))
names = [r[0] for r in rows]; xs = np.arange(len(rows)); w = .36
ax.bar(xs - w/2, [r[1]*100 for r in rows], w, label="mô hình v1", color=C_DIM)
ax.bar(xs + w/2, [r[2]*100 for r in rows], w, label="mô hình v2", color=C_MAIN)
for i, r in enumerate(rows):
d = (r[2] - r[1]) * 100
ax.annotate(f"{d:+.1f}đ", (i + w/2, r[2]*100 + .6), ha="center", fontsize=8,
color=C_BAD if d < 0 else C_MAIN)
ax.set_xticks(xs); ax.set_xticklabels(names); ax.set_ylabel("Độ chính xác (%)")
ax.legend(frameon=False, fontsize=8)
ax.set_title("Độ chính xác của v1 và v2 theo lát cắt")
fig.tight_layout(); fig.savefig(OUT + "mlops06_slice.png"); plt.close(fig)
# =====================================================================
# (C) Hình 9 — Cỡ mẫu cần cho A/B test
# =====================================================================
head("(C) Co mau A/B test cho ti le chuyen doi")
def n_per_arm(p, rel_lift, alpha=0.05, power=0.80):
"""Hai phia, hai nhanh bang nhau, xap xi chuan."""
p2 = p * (1 + rel_lift)
z_a = stats.norm.ppf(1 - alpha / 2)
z_b = stats.norm.ppf(power)
return (z_a + z_b) ** 2 * (p * (1 - p) + p2 * (1 - p2)) / (p2 - p) ** 2
base = 0.05
lifts = np.array([0.01, 0.02, 0.05, 0.10, 0.20])
print(f"CTR nen = {base:.0%}, alpha=0.05 hai phia, power=80%")
print(f"{'lift tuong doi':>16}{'n moi nhanh':>14}{'ngay (100k/ngay/nhanh)':>26}")
C = []
for l in lifts:
n = n_per_arm(base, l)
C.append((l, n, n / 100_000))
print(f"{l:15.0%}{n:14,.0f}{n/100_000:25.1f}")
fine = np.linspace(0.005, 0.25, 200)
fig, ax = plt.subplots(figsize=(5.4, 3.0))
for p0, c in [(0.01, C_BAD), (0.05, C_MAIN), (0.20, C_WARN)]:
ax.plot(fine * 100, [n_per_arm(p0, l) for l in fine], color=c, label=f"CTR nền {p0:.0%}")
ax.set_yscale("log"); ax.set_xlabel("Mức cải thiện tương đối cần phát hiện (%)")
ax.set_ylabel("Cỡ mẫu mỗi nhánh (log)")
ax.set_title("Cỡ mẫu mỗi nhánh theo mức cải thiện tương đối cần phát hiện")
ax.legend(frameon=False, fontsize=8)
fig.tight_layout(); fig.savefig(OUT + "mlops09_abtest.png"); plt.close(fig)
# =====================================================================
# (D) Hình 10 — Ba loại dịch chuyển và cái nào dò được bằng X
# =====================================================================
head("(D) Ba loai dich chuyen: cai nao do duoc ma khong can nhan")
n = 4000
r = np.random.default_rng(7)
Xs = r.normal(0, 1, (n, 2))
w_true = np.array([1.5, -1.0])
def label(X, w, noise=0.0, r=r):
p = 1 / (1 + np.exp(-(X @ w)))
return (r.random(len(X)) < p).astype(int)
ys = label(Xs, w_true)
clf = LogisticRegression().fit(Xs, ys)
scen = {}
# 1. Covariate shift: P(X) doi, P(Y|X) giu nguyen
Xc = r.normal(1.2, 1.0, (n, 2)); yc = label(Xc, w_true)
scen["Covariate shift"] = (Xc, yc)
# 2. Label shift: P(Y) doi, P(X|Y) giu nguyen -> lay mau lai theo nhan
Xl_pool, yl_pool = r.normal(0, 1, (4 * n, 2)), None
yl_pool = label(Xl_pool, w_true)
idx1 = np.flatnonzero(yl_pool == 1); idx0 = np.flatnonzero(yl_pool == 0)
take = np.r_[r.choice(idx1, int(.8 * n)), r.choice(idx0, int(.2 * n))]
scen["Label shift"] = (Xl_pool[take], yl_pool[take])
# 3. Concept drift: P(X) giu nguyen, P(Y|X) doi
Xd = r.normal(0, 1, (n, 2)); yd = label(Xd, np.array([-1.0, 1.5]))
scen["Concept drift"] = (Xd, yd)
print(f"{'Kịch bản':<18}{'Độ chính xác':>14}{'KS trên x1 (p)':>17}{'KS trên x2 (p)':>17}{'Dò được bằng X?':>19}")
base_acc = clf.score(Xs, ys)
print(f"{'Không dịch chuyển':<18}{base_acc*100:13.1f}%{'—':>17}{'—':>17}{'—':>19}")
D = []
for name, (X2, y2) in scen.items():
acc = clf.score(X2, y2)
p1 = stats.ks_2samp(Xs[:, 0], X2[:, 0]).pvalue
p2 = stats.ks_2samp(Xs[:, 1], X2[:, 1]).pvalue
seen = "CÓ" if min(p1, p2) < 1e-3 else "KHÔNG"
D.append((name, acc, p1, p2, seen))
print(f"{name:<18}{acc*100:13.1f}%{p1:17.2e}{p2:17.2e}{seen:>19}")
fig, axes = plt.subplots(1, 3, figsize=(9.6, 3.0), sharex=True, sharey=True)
for ax_, (name, (X2, y2)) in zip(axes, scen.items()):
ax_.scatter(Xs[:600, 0], Xs[:600, 1], s=5, alpha=.35, color=C_DIM, label="nguồn")
ax_.scatter(X2[:600, 0], X2[:600, 1], s=5, alpha=.45, color=C_MAIN, label="đích")
xx = np.linspace(-4, 5, 50)
ax_.plot(xx, -(clf.coef_[0, 0] * xx + clf.intercept_[0]) / clf.coef_[0, 1],
color=C_BAD, lw=1.4, label="biên mô hình")
acc = clf.score(X2, y2)
ax_.set_title(f"{name}\nđộ chính xác {acc*100:.1f}%", fontsize=9)
ax_.set_xlabel("$x_1$")
axes[0].set_ylabel("$x_2$"); axes[0].set_xlim(-4, 5); axes[0].set_ylim(-4, 5)
axes[0].legend(frameon=False, fontsize=7, loc="upper left")
fig.tight_layout(); fig.savefig(OUT + "mlops10_shifts.png"); plt.close(fig)
# =====================================================================
# (E) Hình 11 — PSI dưới giả thuyết không phụ thuộc n và số bin
# =====================================================================
head("(E) PSI khi KHONG he co dich chuyen: E[PSI] ~ 2(k-1)/n")
def psi(a, b, bins):
edges = np.quantile(a, np.linspace(0, 1, bins + 1)); edges[0], edges[-1] = -np.inf, np.inf
pa = np.histogram(a, edges)[0] / len(a)
pb = np.histogram(b, edges)[0] / len(b)
eps = 1e-6
pa, pb = np.clip(pa, eps, None), np.clip(pb, eps, None)
return float(np.sum((pb - pa) * np.log(pb / pa)))
print(f"{'n mỗi mẫu':>11}{'k=5':>11}{'k=10':>11}{'k=20':>11} | lý thuyết 2(k-1)/n với k=10")
E = []
for n_ in [200, 500, 1000, 5000, 20000]:
row = []
for k in [5, 10, 20]:
vals = [psi(rng.normal(size=n_), rng.normal(size=n_), k) for _ in range(300)]
row.append(np.mean(vals))
E.append((n_, *row, 2 * 9 / n_))
print(f"{n_:11d}{row[0]:11.4f}{row[1]:11.4f}{row[2]:11.4f} | {2*9/n_:.4f}")
fig, ax = plt.subplots(figsize=(5.6, 3.1))
ns = np.array([e[0] for e in E])
for j, k in enumerate([5, 10, 20]):
ax.plot(ns, [e[1 + j] for e in E], "-o", ms=4, label=f"đo được, k={k} bin")
ax.plot(ns, 2 * (k - 1) / ns, ":", lw=1.2, color="k", alpha=.5)
ax.axhline(0.1, ls="--", lw=1.1, color=C_WARN)
ax.axhline(0.25, ls="--", lw=1.1, color=C_BAD)
ax.annotate("ngưỡng 0,25 “dịch chuyển mạnh”", (ns[0], 0.26), fontsize=7.5, color=C_BAD)
ax.annotate("ngưỡng 0,10", (ns[0], 0.105), fontsize=7.5, color=C_WARN)
ax.set_xscale("log"); ax.set_yscale("log")
ax.set_xlabel("cỡ mẫu n mỗi phía"); ax.set_ylabel("PSI trung bình khi hai mẫu CÙNG phân phối")
ax.set_title("Đường chấm là $2(k-1)/n$")
ax.legend(frameon=False, fontsize=7.5)
fig.tight_layout(); fig.savefig(OUT + "mlops11_psi_null.png"); plt.close(fig)
# =====================================================================
# (F) Hình 12 — Tỉ lệ báo động giả của ngưỡng PSI cố định
# =====================================================================
head("(F) Ti le bao dong gia va do nhay: PSI 0.25 so voi kiem dinh KS")
def rates(n_, delta, trials=400, k=10, alpha=0.01):
fp_psi = fp_ks = 0
for _ in range(trials):
a = rng.normal(0, 1, n_); b = rng.normal(delta, 1, n_)
if psi(a, b, k) > 0.25: fp_psi += 1
if stats.ks_2samp(a, b).pvalue < alpha: fp_ks += 1
return fp_psi / trials, fp_ks / trials
print(f"{'n':>7}{'δ':>6}{'PSI>0,25 báo':>15}{'KS p<0,01 báo':>16}")
F = []
for n_ in [50, 100, 200, 1000, 10000]:
for d in [0.0, 0.1, 0.3]:
rp, rk = rates(n_, d)
F.append((n_, d, rp, rk))
print(f"{n_:7d}{d:6.1f}{rp*100:14.0f}%{rk*100:15.0f}%")
fig, ax = plt.subplots(1, 2, figsize=(8.4, 3.0), sharey=True)
deltas = np.linspace(0, 0.4, 9)
for n_, c in [(100, C_BAD), (1000, C_WARN), (10000, C_MAIN)]:
rp = [rates(n_, d, trials=250)[0] for d in deltas]
rk = [rates(n_, d, trials=250)[1] for d in deltas]
ax[0].plot(deltas, np.array(rp) * 100, "-o", ms=3.5, color=c, label=f"n={n_}")
ax[1].plot(deltas, np.array(rk) * 100, "-o", ms=3.5, color=c, label=f"n={n_}")
ax[0].set_title("Quy tắc PSI > 0,25"); ax[1].set_title("Kiểm định KS, p < 0,01")
for a_ in ax:
a_.set_xlabel("độ dịch chuyển thật $\\delta$ (đơn vị $\\sigma$)"); a_.legend(frameon=False, fontsize=8)
ax[0].set_ylabel("% lần báo có dịch chuyển")
fig.tight_layout(); fig.savefig(OUT + "mlops12_detect.png"); plt.close(fig)
# =====================================================================
# (G) Hình 13 — Mô hình cũ đi và nhịp huấn luyện lại
# =====================================================================
head("(G) Do cu cua mo hinh va nhip huan luyen lai")
WEEKS, DRIFT = 52, 0.035 # radian moi tuan, bien quyet dinh xoay dan
def world(week, m=4000, r=None):
r = r or np.random.default_rng(1000 + week)
X = r.normal(0, 1, (m, 2))
th = DRIFT * week
w = np.array([np.cos(th), np.sin(th)]) * 2.0
return X, label(X, w, r=r)
def run(cadence):
"""cadence = so tuan giua hai lan huan luyen lai; None = khong bao gio."""
X0, y0 = world(0); model = LogisticRegression().fit(X0, y0)
acc = []
for wk in range(WEEKS):
Xw, yw = world(wk)
acc.append(model.score(Xw, yw))
if cadence and (wk + 1) % cadence == 0:
model = LogisticRegression().fit(Xw, yw)
return np.array(acc)
G = {}
for cad, lab in [(None, "không huấn luyện lại"), (12, "mỗi quý"), (4, "mỗi tháng"), (1, "mỗi tuần")]:
a = run(cad); G[lab] = a
print(f"{lab:<24} trung bình {a.mean()*100:5.2f}% thấp nhất {a.min()*100:5.2f}% tuần 52: {a[-1]*100:5.2f}%")
stale_cost = (G["mỗi tuần"].mean() - G["không huấn luyện lại"].mean()) * 100
print(f"\nGia cua viec khong huan luyen lai: {stale_cost:.2f} diem phan tram do chinh xac trung binh")
fig, ax = plt.subplots(figsize=(6.0, 3.1))
for lab, a in G.items():
ax.plot(np.arange(WEEKS), a * 100, lw=1.6, label=lab)
ax.set_xlabel("tuần kể từ lần huấn luyện đầu"); ax.set_ylabel("độ chính xác (%)")
ax.set_title("Độ chính xác theo tuần với bốn nhịp huấn luyện lại")
ax.legend(frameon=False, fontsize=8)
fig.tight_layout(); fig.savefig(OUT + "mlops13_staleness.png"); plt.close(fig)
# =====================================================================
# (H) Hình 14 — Vòng phản hồi thoái hoá
# =====================================================================
head("(H) Vong phan hoi thoai hoa: do da dang sup the nao")
N_ITEM, ROUNDS, USERS, TOPK, WARMUP = 2000, 30, 3000, 10, 200
true_q = rng.beta(1.6, 9.0, N_ITEM) # it mon that tot, phan lon tam thuong # chat luong that, he thong khong biet
def simulate(eps, seed=11):
"""Mô phỏng hệ gợi ý tự huấn luyện lại trên log click của chính nó."""
r = np.random.default_rng(seed)
shown = np.zeros(N_ITEM); clicks = np.zeros(N_ITEM)
# Vòng khởi động: hiển thị ngẫu nhiên một ít, nên ước lượng ban đầu rất nhiễu.
imp = r.choice(N_ITEM, WARMUP)
np.add.at(shown, imp, 1)
np.add.at(clicks, imp, r.random(WARMUP) < true_q[imp])
ever = set(imp.tolist())
cov, quality, found = [], [], []
elite = set(np.argsort(-true_q)[:20].tolist()) # 1% danh muc tot nhat
for _ in range(ROUNDS):
score = np.where(shown > 0, clicks / np.maximum(shown, 1), 0.0)
top = np.argsort(-score)[:TOPK]
pool = top
if eps > 0: # thay eps ti le bang muc ngau nhien
n_rand = max(1, int(round(eps * TOPK)))
pool = np.r_[top[: TOPK - n_rand], r.choice(N_ITEM, n_rand, replace=False)]
imp = r.choice(pool, USERS)
cl = r.random(USERS) < true_q[imp]
np.add.at(shown, imp, 1); np.add.at(clicks, imp, cl)
ever.update(np.unique(imp).tolist())
cov.append(len(ever) / N_ITEM)
quality.append(true_q[top].mean())
found.append(len(elite & ever) / len(elite))
return np.array(cov), np.array(quality), np.array(found)
H = {}
print(f"{'Chiến lược':<24}{'phủ danh mục':>14}{'chất lượng top-10':>20}{'đã thấy top-1% tốt nhất':>24}")
for eps, lab in [(0.0, "không ngẫu nhiên hoá"), (0.1, "ε = 10% ngẫu nhiên"), (0.3, "ε = 30% ngẫu nhiên")]:
covs, quals, fnds = [], [], []
for sd in range(20): # trung binh 20 lan chay
c, q, fd = simulate(eps, seed=11 + sd)
covs.append(c); quals.append(q); fnds.append(fd)
cov, qual, fnd = np.mean(covs, 0), np.mean(quals, 0), np.mean(fnds, 0)
H[lab] = (cov, qual, fnd)
print(f"{lab:<24}{cov[-1]*100:13.1f}%{qual[-1]:20.3f}{fnd[-1]*100:23.0f}%")
ceil10 = np.sort(true_q)[-10:].mean()
print(f"Tran co the dat (top-10 that): {ceil10:.3f} | trung binh danh muc: {true_q.mean():.3f}")
fig, ax = plt.subplots(1, 3, figsize=(10.2, 2.9))
for lab, (cov, qual, fnd) in H.items():
ax[0].plot(cov * 100, lw=1.7, label=lab)
ax[1].plot(qual, lw=1.7, label=lab)
ax[2].plot(fnd * 100, lw=1.7, label=lab)
ax[1].axhline(true_q.max(), ls="--", lw=1.1, color=C_DIM)
ax[1].annotate("món tốt nhất thật sự", (0.4, true_q.max() - .012), fontsize=7.5, color=C_DIM)
ax[0].set_ylabel("% danh mục từng được hiển thị"); ax[0].set_title("Độ đa dạng")
ax[1].set_ylabel("chất lượng thật của top-10"); ax[1].set_title("Chất lượng")
ax[2].set_ylabel("% nhóm 1% tốt nhất đã từng được hiện"); ax[2].set_title("Khả năng khám phá")
for a_ in ax: a_.set_xlabel("vòng huấn luyện lại")
ax[0].legend(frameon=False, fontsize=7.5)
fig.tight_layout(); fig.savefig(OUT + "mlops14_feedback.png"); plt.close(fig)
print("\nDa luu 8 hinh vao " + OUT)
Kết quả chạy — experiments_output.txt
========================================================================
(A) Ngân sach do tre: p99 cua max-of-k (tail at scale)
========================================================================
lognormal: median=20.0 ms, p99=100.0 ms -> mu=2.9957, sigma=0.6918
k p99 cua max (ms) % yeu cau co >=1 nhanh vuot p99
1 100.0 1.0%
2 118.8 2.0%
5 146.4 4.9%
10 169.5 9.6%
20 194.7 18.2%
50 231.4 39.5%
100 261.9 63.4%
========================================================================
(B) Danh gia theo lat cat: chi so gop che mat su that
========================================================================
v2 = v1 + đặc trưng x2, thứ chỉ có tín hiệu với nhóm A
Lát cắt v1 v2 thay đổi
Tổng thể 69.14% 88.96% +19.83 đ
Nhóm A (85%) 68.99% 94.48% +25.49 đ
Nhóm B (15%) 69.97% 58.35% -11.62 đ
trọng số v2 học được: x1=1.07, x2=2.28
========================================================================
(C) Co mau A/B test cho ti le chuyen doi
========================================================================
CTR nen = 5%, alpha=0.05 hai phia, power=80%
lift tuong doi n moi nhanh ngay (100k/ngay/nhanh)
1% 2,996,694 30.0
2% 752,700 7.5
5% 122,121 1.2
10% 31,231 0.3
20% 8,155 0.1
========================================================================
(D) Ba loai dich chuyen: cai nao do duoc ma khong can nhan
========================================================================
Kịch bản Độ chính xác KS trên x1 (p) KS trên x2 (p) Dò được bằng X?
Không dịch chuyển 75.6% — — —
Covariate shift 76.4% 0.00e+00 0.00e+00 CÓ
Label shift 75.1% 1.23e-29 2.23e-07 CÓ
Concept drift 26.9% 4.83e-01 4.28e-02 KHÔNG
========================================================================
(E) PSI khi KHONG he co dich chuyen: E[PSI] ~ 2(k-1)/n
========================================================================
n mỗi mẫu k=5 k=10 k=20 | lý thuyết 2(k-1)/n với k=10
200 0.0399 0.0943 0.2050 | 0.0900
500 0.0164 0.0353 0.0795 | 0.0360
1000 0.0080 0.0179 0.0381 | 0.0180
5000 0.0015 0.0037 0.0076 | 0.0036
20000 0.0004 0.0009 0.0019 | 0.0009
========================================================================
(F) Ti le bao dong gia va do nhay: PSI 0.25 so voi kiem dinh KS
========================================================================
n δ PSI>0,25 báo KS p<0,01 báo
50 0.0 73% 0%
50 0.1 78% 1%
50 0.3 82% 5%
100 0.0 24% 1%
100 0.1 24% 2%
100 0.3 54% 24%
200 0.0 0% 0%
200 0.1 0% 5%
200 0.3 17% 49%
1000 0.0 0% 2%
1000 0.1 0% 22%
1000 0.3 0% 100%
10000 0.0 0% 0%
10000 0.1 0% 100%
10000 0.3 0% 100%
========================================================================
(G) Do cu cua mo hinh va nhip huan luyen lai
========================================================================
không huấn luyện lại trung bình 64.61% thấp nhất 45.42% tuần 52: 45.48%
mỗi quý trung bình 76.89% thấp nhất 74.50% tuần 52: 78.25%
mỗi tháng trung bình 77.68% thấp nhất 75.80% tuần 52: 78.25%
mỗi tuần trung bình 77.80% thấp nhất 75.72% tuần 52: 78.72%
Gia cua viec khong huan luyen lai: 13.19 diem phan tram do chinh xac trung binh
========================================================================
(H) Vong phan hoi thoai hoa: do da dang sup the nao
========================================================================
Chiến lược phủ danh mục chất lượng top-10 đã thấy top-1% tốt nhất
không ngẫu nhiên hoá 9.5% 0.351 8%
ε = 10% ngẫu nhiên 10.8% 0.384 10%
ε = 30% ngẫu nhiên 13.4% 0.402 12%
Tran co the dat (top-10 that): 0.555 | trung binh danh muc: 0.153
Da luu 8 hinh vao figs/
code/mlops/bai_tap.py
viết thêm cho repo nàyTải vềSinh số liệu cho lời giải bài tập Chương 14: phân phối của PSI khi không có dịch chuyển (công thức và mô phỏng), cỡ mẫu A/B test, phần đuôi khi toả nhánh 25 dịch vụ, ghép theo thời điểm, nhịp huấn luyện lại theo ba tốc độ dịch chuyển, và quét ε trong mô phỏng vòng phản hồi với 30 và 300 vòng.
Xem mã nguồn
"""
Số liệu cho lời giải các bài tập Chương 14 của giáo trình MLOps.
Chạy: python code/mlops/bai_tap.py
Mọi nguồn ngẫu nhiên đều có hạt giống cố định, nên chạy lại cho kết quả y hệt.
Bài 1 PSI khi không có dịch chuyển: công thức tiệm cận và mô phỏng
Bài 2 Cỡ mẫu A/B test và các cách rút ngắn thời gian
Bài 3 Đuôi độ trễ khi toả nhánh 25 dịch vụ
Bài 4 Ghép theo thời điểm (as-of join) trên bảng nhỏ của đề bài
Bài 7 Nhịp huấn luyện lại theo tốc độ dịch chuyển
Bài 8 Quét epsilon trong mô phỏng vòng phản hồi, 30 và 300 vòng
"""
import numpy as np
from scipy import stats
from scipy.optimize import brentq
from sklearn.linear_model import LogisticRegression
def head(t):
print("\n" + "=" * 72 + "\n" + t + "\n" + "=" * 72)
# =====================================================================
# Bài 1 — PSI khi không có dịch chuyển, n = 500, k = 10
# =====================================================================
head("Bai 1. PSI khi khong co dich chuyen, n = 500, k = 10")
def psi(a, b, bins):
"""Giống hệt hàm psi trong experiments.py: bin theo phân vị của mẫu tham chiếu."""
edges = np.quantile(a, np.linspace(0, 1, bins + 1)); edges[0], edges[-1] = -np.inf, np.inf
pa = np.histogram(a, edges)[0] / len(a)
pb = np.histogram(b, edges)[0] / len(b)
eps = 1e-6
pa, pb = np.clip(pa, eps, None), np.clip(pb, eps, None)
return float(np.sum((pb - pa) * np.log(pb / pa)))
n1, k1 = 500, 10
rng = np.random.default_rng(2026)
sim = np.array([psi(rng.normal(size=n1), rng.normal(size=n1), k1) for _ in range(20000)])
print(f"Ky vong theo cong thuc 2(k-1)/n : {2 * (k1 - 1) / n1:.4f}")
print(f"Trung binh mo phong (20 000 lan) : {sim.mean():.4f}")
print(f"{'phan vi':>10}{'cong thuc chi2':>18}{'mo phong':>12}")
for q in [0.95, 0.99, 0.999]:
print(f"{q:10.3f}{2 / n1 * stats.chi2.ppf(q, k1 - 1):18.4f}{np.quantile(sim, q):12.4f}")
p_formula = stats.chi2.sf(0.10 * n1 / 2, k1 - 1)
p_sim = (sim > 0.10).mean()
print(f"P(PSI > 0,10) theo cong thuc : {p_formula:.5f}")
print(f"P(PSI > 0,10) theo mo phong : {p_sim:.5f}")
print(f"So bao dong gia moi ngay, 200 dac trung: {200 * p_formula:.2f}")
print(f"n nho nhat de E[PSI] < 0,01 : n > {2 * (k1 - 1) / 0.01:.0f}")
# =====================================================================
# Bài 2 — Cỡ mẫu A/B test
# =====================================================================
head("Bai 2. Co mau A/B test: nen 2%, cai thien tuong doi 5%")
def n_per_arm(p, rel_lift, alpha=0.05, power=0.80):
"""Giống hàm n_per_arm trong experiments.py: hai phía, hai nhánh bằng nhau."""
p2 = p * (1 + rel_lift)
z_a = stats.norm.ppf(1 - alpha / 2)
z_b = stats.norm.ppf(power)
return (z_a + z_b) ** 2 * (p * (1 - p) + p2 * (1 - p2)) / (p2 - p) ** 2
n2 = n_per_arm(0.02, 0.05)
print(f"z_(0,975) = {stats.norm.ppf(0.975):.5f}, z_(0,80) = {stats.norm.ppf(0.80):.5f}")
print(f"Co mau moi nhanh : {n2:,.0f}")
print(f"So ngay voi 40 000 luot/nhanh/ngay : {n2 / 40000:.2f}")
print(f"Giam phuong sai 40% (CUPED) : {0.6 * n2:,.0f} mau, {0.6 * n2 / 40000:.2f} ngay")
lift_half = brentq(lambda l: n_per_arm(0.02, l) - n2 / 2, 0.05, 0.2)
print(f"Muc cai thien de co mau giam mot nua: {lift_half * 100:.2f}% "
f"({n_per_arm(0.02, lift_half):,.0f} mau)")
print(f"Luc kiem dinh neu giu 5% va chi chay {n2 / 2 / 40000:.2f} ngay: "
f"{stats.norm.cdf(np.sqrt(n2 / 2 * (0.02 * 0.05) ** 2 / (0.02 * 0.98 + 0.021 * 0.979)) - stats.norm.ppf(0.975)):.3f}")
# =====================================================================
# Bài 3 — Toả nhánh 25 dịch vụ
# =====================================================================
head("Bai 3. Toa nhanh 25 dich vu, moi dich vu p99 = 80 ms")
k3 = 25
q_each = 0.99 ** (1 / k3)
print(f"Ti le yeu cau gap it nhat mot nhanh cham: {1 - 0.99 ** k3:.4f}")
print(f"Phan vi moi nhanh can dat : {q_each:.6f} "
f"(1 trong {1 / (1 - q_each):,.0f} loi goi duoc phep cham)")
for k in [25, 10, 5, 1]:
print(f" k = {k:3d}: 1 - 0,99^k = {1 - 0.99 ** k:.4f}")
# =====================================================================
# Bài 4 — Ghép theo thời điểm
# =====================================================================
head("Bai 4. Ghep theo thoi diem tren bang cua de bai")
features = [ # (user_id, valid_from, tong_don_hang)
(7, "2026-01-01", 3),
(7, "2026-03-01", 19),
(7, "2026-06-01", 52),
]
labels = [ # (user_id, event_time, nhan)
(7, "2026-02-10", 0),
(7, "2026-04-15", 1),
]
def as_of(user, t):
"""Bản ghi mới nhất có valid_from <= t (ngày dạng ISO so sánh được như chuỗi)."""
rows = [f for f in features if f[0] == user and f[1] <= t]
return max(rows, key=lambda f: f[1]) if rows else None
print("Dung (as-of join):")
for u, t, y in labels:
f = as_of(u, t)
print(f" user {u} {t} nhan={y} tong_don_hang={f[2]} (tu ban ghi {f[1]})")
latest = max(features, key=lambda f: f[1])
print("Sai (ghep vao gia tri hien tai):")
for u, t, y in labels:
print(f" user {u} {t} nhan={y} tong_don_hang={latest[2]}")
print(f"Sai (JOIN vao bang lich su, quen dieu kien thoi gian): "
f"{len(labels) * len(features)} dong thay vi {len(labels)}")
# =====================================================================
# Bài 7 — Nhịp huấn luyện lại theo tốc độ dịch chuyển
# =====================================================================
head("Bai 7. Nhip huan luyen lai theo toc do dich chuyen")
WEEKS = 52
def label(X, w, r):
p = 1 / (1 + np.exp(-(X @ w)))
return (r.random(len(X)) < p).astype(int)
def world(week, drift, m=4000):
"""Giống world() trong experiments.py, thêm tham số drift."""
r = np.random.default_rng(1000 + week)
X = r.normal(0, 1, (m, 2))
th = drift * week
w = np.array([np.cos(th), np.sin(th)]) * 2.0
return X, label(X, w, r)
def run(cadence, drift):
X0, y0 = world(0, drift); model = LogisticRegression().fit(X0, y0)
acc = []
for wk in range(WEEKS):
Xw, yw = world(wk, drift)
acc.append(model.score(Xw, yw))
if cadence and (wk + 1) % cadence == 0:
model = LogisticRegression().fit(Xw, yw)
return np.mean(acc)
CADENCES = [(None, "khong bao gio"), (12, "moi quy"), (4, "moi thang"), (2, "moi 2 tuan"), (1, "moi tuan")]
print(f"{'drift (rad/tuan)':>17}" + "".join(f"{lab:>15}" for _, lab in CADENCES) + " nhip thua nhat trong 1 diem")
for drift in [0.010, 0.035, 0.080]:
accs = [run(c, drift) * 100 for c, _ in CADENCES]
best = accs[-1]
ok = [lab for (c, lab), a in zip(CADENCES, accs) if c and best - a <= 1.0]
print(f"{drift:17.3f}" + "".join(f"{a:14.2f}%" for a in accs) + f" {ok[0]}")
# =====================================================================
# Bài 8 — Quét epsilon trong mô phỏng vòng phản hồi
# =====================================================================
head("Bai 8. Quet epsilon: chat luong he thong biet va chat luong giao toi nguoi dung")
N_ITEM, USERS, TOPK, WARMUP = 2000, 3000, 10, 200
true_q = np.random.default_rng(88).beta(1.6, 9.0, N_ITEM) # danh muc rieng cua bai tap
ceil10 = np.sort(true_q)[-10:].mean()
print(f"Danh muc cua bai tap: tran top-10 = {ceil10:.3f}, trung binh = {true_q.mean():.3f}")
def simulate(eps, rounds, seed):
"""Như simulate() trong experiments.py, thêm chất lượng thật của các món đã hiển thị."""
r = np.random.default_rng(seed)
shown = np.zeros(N_ITEM); clicks = np.zeros(N_ITEM)
imp = r.choice(N_ITEM, WARMUP)
np.add.at(shown, imp, 1)
np.add.at(clicks, imp, r.random(WARMUP) < true_q[imp])
believed, served = [], []
for _ in range(rounds):
score = np.where(shown > 0, clicks / np.maximum(shown, 1), 0.0)
top = np.argsort(-score)[:TOPK]
pool = top
if eps > 0:
n_rand = max(1, int(round(eps * TOPK)))
pool = np.r_[top[: TOPK - n_rand], r.choice(N_ITEM, n_rand, replace=False)]
imp = r.choice(pool, USERS)
cl = r.random(USERS) < true_q[imp]
np.add.at(shown, imp, 1); np.add.at(clicks, imp, cl)
believed.append(true_q[top].mean())
served.append(true_q[imp].mean())
return np.array(believed), np.array(served)
for rounds in [30, 300]:
print(f"\n{rounds} vong, trung binh 20 lan chay")
print(f"{'epsilon':>8}{'top-10 vong cuoi':>18}{'% tran':>8}{'giao, ca ky':>13}{'giao, 5 vong cuoi':>19}")
# epsilon * 10 là số vị trí ngẫu nhiên trong danh sách 10 món, nên chỉ xét bội của 0,1
for eps in [0.0, 0.1, 0.2, 0.3, 0.4, 0.5]:
bs, ss = [], []
for sd in range(20):
b, s = simulate(eps, rounds, seed=500 + sd)
bs.append(b); ss.append(s)
b, s = np.mean(bs, 0), np.mean(ss, 0)
print(f"{eps:8.2f}{b[-1]:18.3f}{b[-1] / ceil10 * 100:7.0f}%{s.mean():13.4f}{s[-5:].mean():19.4f}")
Kết quả chạy — bai_tap_output.txt
========================================================================
Bai 1. PSI khi khong co dich chuyen, n = 500, k = 10
========================================================================
Ky vong theo cong thuc 2(k-1)/n : 0.0360
Trung binh mo phong (20 000 lan) : 0.0364
phan vi cong thuc chi2 mo phong
0.950 0.0677 0.0684
0.990 0.0867 0.0874
0.999 0.1115 0.1131
P(PSI > 0,10) theo cong thuc : 0.00297
P(PSI > 0,10) theo mo phong : 0.00360
So bao dong gia moi ngay, 200 dac trung: 0.59
n nho nhat de E[PSI] < 0,01 : n > 1800
========================================================================
Bai 2. Co mau A/B test: nen 2%, cai thien tuong doi 5%
========================================================================
z_(0,975) = 1.95996, z_(0,80) = 0.84162
Co mau moi nhanh : 315,203
So ngay voi 40 000 luot/nhanh/ngay : 7.88
Giam phuong sai 40% (CUPED) : 189,122 mau, 4.73 ngay
Muc cai thien de co mau giam mot nua: 7.11% (157,602 mau)
Luc kiem dinh neu giu 5% va chi chay 3.94 ngay: 0.508
========================================================================
Bai 3. Toa nhanh 25 dich vu, moi dich vu p99 = 80 ms
========================================================================
Ti le yeu cau gap it nhat mot nhanh cham: 0.2222
Phan vi moi nhanh can dat : 0.999598 (1 trong 2,488 loi goi duoc phep cham)
k = 25: 1 - 0,99^k = 0.2222
k = 10: 1 - 0,99^k = 0.0956
k = 5: 1 - 0,99^k = 0.0490
k = 1: 1 - 0,99^k = 0.0100
========================================================================
Bai 4. Ghep theo thoi diem tren bang cua de bai
========================================================================
Dung (as-of join):
user 7 2026-02-10 nhan=0 tong_don_hang=3 (tu ban ghi 2026-01-01)
user 7 2026-04-15 nhan=1 tong_don_hang=19 (tu ban ghi 2026-03-01)
Sai (ghep vao gia tri hien tai):
user 7 2026-02-10 nhan=0 tong_don_hang=52
user 7 2026-04-15 nhan=1 tong_don_hang=52
Sai (JOIN vao bang lich su, quen dieu kien thoi gian): 6 dong thay vi 2
========================================================================
Bai 7. Nhip huan luyen lai theo toc do dich chuyen
========================================================================
drift (rad/tuan) khong bao gio moi quy moi thang moi 2 tuan moi tuan nhip thua nhat trong 1 diem
0.010 76.15% 77.67% 77.74% 77.76% 77.76% moi quy
0.035 64.61% 76.89% 77.68% 77.78% 77.80% moi quy
0.080 45.08% 73.04% 76.96% 77.50% 77.66% moi thang
========================================================================
Bai 8. Quet epsilon: chat luong he thong biet va chat luong giao toi nguoi dung
========================================================================
Danh muc cua bai tap: tran top-10 = 0.610, trung binh = 0.154
30 vong, trung binh 20 lan chay
epsilon top-10 vong cuoi % tran giao, ca ky giao, 5 vong cuoi
0.00 0.360 59% 0.3480 0.3604
0.10 0.390 64% 0.3495 0.3744
0.20 0.404 66% 0.3389 0.3686
0.30 0.425 70% 0.3302 0.3626
0.40 0.431 71% 0.3108 0.3428
0.50 0.453 74% 0.2932 0.3246
300 vong, trung binh 20 lan chay
epsilon top-10 vong cuoi % tran giao, ca ky giao, 5 vong cuoi
0.00 0.360 59% 0.3590 0.3601
0.10 0.488 80% 0.4237 0.4615
0.20 0.518 85% 0.4187 0.4588
0.30 0.541 89% 0.4034 0.4424
0.40 0.559 92% 0.3864 0.4153
0.50 0.570 93% 0.3602 0.3817
code/mlops/fig_diagrams.py
viết thêm cho repo nàyTải vềSinh Hình 1–5 và Hình 8: phần mã học máy giữa các thành phần hạ tầng, vòng đời của hệ thống học máy, ba mức tự động hoá, ba loại lỗi dữ liệu, tính đúng theo thời điểm, và bốn chiến lược ra mắt. Các sơ đồ vẽ bằng matplotlib để sửa được cùng nội dung bài.
Xem mã nguồn
"""Sinh Hình 1–5 và Hình 8 của giáo trình MLOps: các sơ đồ khái niệm.
Đây là hình vẽ tay bằng matplotlib chứ không phải kết quả thí nghiệm, nhưng vẫn
để trong mã nguồn để sửa được và để mọi chi tiết khớp với chữ trong bài.
python code/mlops/fig_diagrams.py
"""
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib.patches import FancyBboxPatch, FancyArrowPatch
OUT = "figs/"
plt.rcParams.update({"figure.dpi": 150, "font.size": 9})
INK, DIM = "#1f1d1a", "#8a857c"
TEAL, TEAL_BG = "#1f6f68", "#e0efed"
AMBER, AMBER_BG = "#8a5300", "#fbf0db"
RED, RED_BG = "#a13328", "#fbe9e6"
BLUE, BLUE_BG = "#1a5aa8", "#dceafb"
GREEN, GREEN_BG = "#2c6b39", "#e6f2e8"
def box(ax, x, y, w, h, text, fc="white", ec=INK, fs=8.5, weight="normal", lw=1.1, r=0.02):
ax.add_patch(FancyBboxPatch((x, y), w, h, boxstyle=f"round,pad=0,rounding_size={r}",
fc=fc, ec=ec, lw=lw, zorder=2))
ax.text(x + w / 2, y + h / 2, text, ha="center", va="center", fontsize=fs,
color=INK, weight=weight, zorder=3, linespacing=1.45)
def arrow(ax, p, q, color=INK, lw=1.2, style="-|>", ls="-"):
ax.add_patch(FancyArrowPatch(p, q, arrowstyle=style, mutation_scale=11,
color=color, lw=lw, linestyle=ls,
shrinkA=2, shrinkB=2, zorder=4))
def canvas(w, h, xlim=(0, 10), ylim=(0, 6)):
fig, ax = plt.subplots(figsize=(w, h))
ax.set_xlim(*xlim); ax.set_ylim(*ylim); ax.axis("off")
return fig, ax
# ===================================================== Hình 1 — hộp mã ML bé xíu
fig, ax = canvas(8.0, 3.6, (0, 12), (0, 7))
around = [
("Thu thập\ndữ liệu", 0.2, 4.6, 1.9, 1.5, BLUE_BG, BLUE),
("Kiểm định\ndữ liệu", 2.3, 5.2, 1.9, 0.9, BLUE_BG, BLUE),
("Trích xuất\nđặc trưng", 0.2, 2.6, 1.9, 1.7, GREEN_BG, GREEN),
("Quản lý\ncấu hình", 4.4, 5.2, 1.7, 0.9, AMBER_BG, AMBER),
("Hạ tầng\nphục vụ", 8.3, 4.2, 1.9, 1.9, TEAL_BG, TEAL),
("Quản lý\ntài nguyên máy", 10.4, 4.2, 1.5, 1.9, TEAL_BG, TEAL),
("Công cụ\nphân tích", 6.3, 5.2, 1.8, 0.9, AMBER_BG, AMBER),
("Quản lý\nquy trình", 8.3, 2.0, 1.9, 1.9, AMBER_BG, AMBER),
("Giám sát", 10.4, 2.0, 1.5, 1.9, RED_BG, RED),
("Hạ tầng\nphục vụ dữ liệu", 0.2, 0.3, 3.0, 2.0, GREEN_BG, GREEN),
("Kiểm thử và gỡ lỗi", 3.4, 0.3, 4.6, 1.5, RED_BG, RED),
("Xác minh\nmô hình", 8.3, 0.3, 3.6, 1.4, RED_BG, RED),
]
for t, x, y, w, h, fc, ec in around:
box(ax, x, y, w, h, t, fc=fc, ec=ec, fs=8)
box(ax, 4.4, 2.6, 3.6, 2.3, "", fc="#f4f2ec", ec=DIM)
ax.text(6.2, 4.6, "Thu thập đặc trưng", ha="center", va="center", fontsize=8, color=INK, zorder=3)
box(ax, 5.55, 3.35, 1.3, 0.8, "Mã\nML", fc=INK, ec=INK, fs=9, weight="bold")
ax.text(6.2, 3.75, "Mã\nML", ha="center", va="center", fontsize=9.5,
color="white", weight="bold", zorder=5, linespacing=1.3)
ax.text(6.0, 6.55, "Chỉ một phần rất nhỏ của hệ thống ML thật là mã học máy",
ha="center", fontsize=9.5, weight="bold", color=INK)
fig.tight_layout(); fig.savefig(OUT + "mlops01_debt.png"); plt.close(fig)
# ===================================================== Hình 2 — vòng đời là vòng lặp
fig, ax = canvas(8.4, 3.4, (0, 12), (0, 6))
stages = [
("Bài toán\nnghiệp vụ", 0.2, TEAL_BG, TEAL),
("Dữ liệu và\nnhãn", 2.15, BLUE_BG, BLUE),
("Đặc trưng và\nthí nghiệm", 4.1, BLUE_BG, BLUE),
("Đánh giá và\nra mắt", 6.05, GREEN_BG, GREEN),
("Phục vụ", 8.0, GREEN_BG, GREEN),
("Giám sát", 9.95, RED_BG, RED),
]
for t, x, fc, ec in stages:
box(ax, x, 3.0, 1.75, 1.3, t, fc=fc, ec=ec, fs=8.2)
for i in range(len(stages) - 1):
arrow(ax, (stages[i][1] + 1.75, 3.65), (stages[i + 1][1], 3.65))
# ba vong quay lai
loops = [(10.85, 5.05, "dịch chuyển phân phối → huấn luyện lại", RED),
(10.85, 3.05, "lỗi dữ liệu → sửa pipeline", AMBER),
(10.85, 2.25, "chỉ số sản phẩm không nhúc nhích → đổi bài toán", TEAL)]
targets = [(4.98, 3.0), (3.03, 3.0), (1.08, 3.0)]
for (sx, sy, label, col), (tx, ty) in zip(loops, targets):
ax.add_patch(FancyArrowPatch((sx, 3.0), (tx, ty - 0.02),
connectionstyle=f"arc3,rad={0.16 + 0.07 * loops.index((sx, sy, label, col))}",
arrowstyle="-|>", mutation_scale=10, color=col, lw=1.2,
linestyle=(0, (4, 2)), shrinkA=2, shrinkB=2, zorder=1))
ax.text(tx + (sx - tx) / 2, 1.35 - 0.42 * loops.index((sx, sy, label, col)),
label, ha="center", fontsize=7.6, color=col)
ax.text(6.0, 5.5, "Vòng đời ML là một vòng lặp, không phải một đường thẳng",
ha="center", fontsize=9.5, weight="bold", color=INK)
fig.tight_layout(); fig.savefig(OUT + "mlops02_lifecycle.png"); plt.close(fig)
# ===================================================== Hình 3 — ba mức tự động hoá
fig, ax = canvas(8.6, 3.5, (0, 12), (0, 6))
levels = [
("Mức 0 — thủ công", 0.2, ["mọi bước làm tay", "bàn giao mô hình đã\nhuấn luyện cho kỹ sư",
"vài tháng một lần", "không giám sát"], RED_BG, RED),
("Mức 1 — tự động hoá pipeline", 4.1, ["pipeline huấn luyện\nchạy tự động",
"kiểm định dữ liệu và\nmô hình trong pipeline",
"bàn giao cả PIPELINE", "có huấn luyện lại (CT)"], AMBER_BG, AMBER),
("Mức 2 — tự động hoá CI/CD", 8.0, ["CI/CD cho chính pipeline",
"thử nghiệm ý tưởng mới\nrồi triển khai nhanh",
"nhiều pipeline song song", "giám sát khép kín"], GREEN_BG, GREEN),
]
for title, x, items, fc, ec in levels:
box(ax, x, 0.5, 3.7, 4.5, "", fc=fc, ec=ec, lw=1.3)
ax.text(x + 1.85, 4.55, title, ha="center", fontsize=9, weight="bold", color=ec)
for i, it in enumerate(items):
ax.text(x + 0.25, 3.85 - i * 0.92, "•", fontsize=9, color=ec, va="top")
ax.text(x + 0.6, 3.85 - i * 0.92, it, fontsize=8, va="top", color=INK, linespacing=1.4)
for x in (3.95, 7.85):
arrow(ax, (x, 2.7), (x + 0.3, 2.7), color=DIM)
ax.text(6.0, 5.5, "Càng lên mức cao, thứ được bàn giao càng lớn: mô hình → pipeline → hệ thống",
ha="center", fontsize=9.3, weight="bold", color=INK)
fig.tight_layout(); fig.savefig(OUT + "mlops03_levels.png"); plt.close(fig)
# ===================================================== Hình 4 — phổ lỗi dữ liệu
fig, ax = canvas(8.4, 3.2, (0, 12), (0, 5.4))
cells = [
("Lỗi cứng\n(hard)", "đảo cột, tuổi âm,\nkiểu sai, tệp rỗng",
"Dự đoán sai rõ ràng.\nDễ bắt bằng ràng buộc schema.", "Chặn pipeline ngay", RED_BG, RED, 0.2),
("Lỗi mềm\n(soft)", "vài trường null,\nđơn vị đổi, mã lạ",
"Vẫn ra dự đoán hợp lý,\nnên trôi qua kiểm tra.", "Cảnh báo + theo dõi tỉ lệ", AMBER_BG, AMBER, 4.1),
("Dịch chuyển\n(drift)", "phân phối đổi dần\ntheo tuần, theo mùa",
"Không có gì “sai”,\nchỉ là thế giới đã khác.", "Điều tra, có thể huấn luyện lại", BLUE_BG, BLUE, 8.0),
]
for title, ex, why, act, fc, ec, x in cells:
box(ax, x, 0.4, 3.7, 4.2, "", fc=fc, ec=ec, lw=1.3)
ax.text(x + 1.85, 4.15, title, ha="center", fontsize=9.5, weight="bold", color=ec, linespacing=1.3)
ax.text(x + 1.85, 3.25, ex, ha="center", fontsize=8, color=INK, style="italic", linespacing=1.4)
ax.text(x + 1.85, 2.25, why, ha="center", fontsize=8, color=INK, linespacing=1.5)
box(ax, x + 0.3, 0.65, 3.1, 0.65, act, fc="white", ec=ec, fs=8)
arrow(ax, (0.2, 4.95), (11.7, 4.95), color=DIM)
ax.text(0.3, 5.12, "dễ phát hiện, hại ngay", fontsize=8, color=DIM)
ax.text(11.6, 5.12, "khó phát hiện, hại chậm", fontsize=8, color=DIM, ha="right")
fig.tight_layout(); fig.savefig(OUT + "mlops04_errors.png"); plt.close(fig)
# ============================================ Hình 5 — tính đúng theo thời điểm
fig, ax = canvas(8.6, 4.1, (0, 12), (0, 8.0))
ax.text(6.0, 7.6, "Cùng một dòng huấn luyện, hai cách ghép cho hai giá trị khác nhau",
ha="center", fontsize=9.5, weight="bold", color=INK)
for row, (lab, col, used, src) in enumerate(
[("ĐÚNG — ghép theo thời điểm", GREEN, "31", 4.0),
("SAI — ghép giá trị hiện tại", RED, "480", 10.4)]):
y = 5.3 - row * 3.35
ax.text(0.5, y + 1.25, lab, fontsize=9, weight="bold", color=col)
ax.plot([0.5, 11.5], [y, y], color=DIM, lw=1.0, zorder=1)
for t, x in [("1/3", 1.6), ("15/3", 4.0), ("1/4", 6.6), ("hôm nay", 10.4)]:
ax.plot([x], [y], "o", ms=4.5, color=DIM, zorder=3)
ax.text(x, y - 0.34, t, ha="center", fontsize=7.5, color=DIM, va="top")
ax.plot([6.6, 6.6], [y - 0.12, y + 0.95], color=AMBER, lw=1.3, ls="--", zorder=2)
ax.text(6.75, y + 0.9, "thời điểm cần dự đoán", fontsize=7.6, color=AMBER, va="top")
for x, v in [(1.6, "12"), (4.0, "31"), (10.4, "480")]:
hot = abs(x - src) < 0.01
box(ax, x - 0.42, y + 0.18, 0.84, 0.46, v,
fc=(GREEN_BG if row == 0 else RED_BG) if hot else "white",
ec=col if hot else DIM, fs=8, weight="bold" if hot else "normal")
ax.add_patch(FancyArrowPatch((src, y + 0.14), (2.2, y - 0.88), arrowstyle="-|>",
mutation_scale=11, color=col, lw=1.5,
connectionstyle="arc3,rad=-0.09" if row else "arc3,rad=0.3",
shrinkA=3, shrinkB=3, zorder=4))
box(ax, 0.5, y - 1.48, 5.8, 0.6,
f"giá trị đưa vào tập huấn luyện = {used}", fc="white", ec=col, fs=8.4)
if row == 1:
ax.text(6.6, y - 1.18, "← thông tin của tương lai, lúc dự đoán thật không hề có",
fontsize=8, color=RED, va="center")
fig.tight_layout(); fig.savefig(OUT + "mlops05_pit.png"); plt.close(fig)
# ===================================================== Hình 8 — chiến lược ra mắt
fig, ax = canvas(8.6, 4.0, (0, 12), (0, 7.4))
ax.text(6.0, 7.05, "Bốn cách đưa mô hình mới ra, khác nhau ở chỗ ai chịu rủi ro",
ha="center", fontsize=9.5, weight="bold", color=INK)
strategies = [
("Shadow", 0.2, 3.9, "100% lưu lượng chạy qua cả hai,\nchỉ bản cũ trả lời người dùng",
"rủi ro cho người dùng: 0\nnhưng không đo được phản ứng thật", TEAL_BG, TEAL),
("Canary", 6.2, 3.9, "1% → 5% → 25% → 100%,\ndừng lại nếu chỉ số xấu đi",
"hạn chế bán kính thiệt hại\ncần chỉ số đủ nhạy ở 1%", GREEN_BG, GREEN),
("Blue–green", 0.2, 0.5, "hai môi trường song song,\nđổi toàn bộ trong một nhịp",
"quay lui tức thì\nnhưng thiệt hại là 100% nếu hỏng", AMBER_BG, AMBER),
("A/B test", 6.2, 0.5, "chia người dùng ngẫu nhiên,\nđo chỉ số sản phẩm có ý nghĩa thống kê",
"câu trả lời nhân quả duy nhất\nnhưng tốn thời gian và lưu lượng", BLUE_BG, BLUE),
]
for name, x, y, how, tradeoff, fc, ec in strategies:
box(ax, x, y, 5.6, 2.9, "", fc=fc, ec=ec, lw=1.3)
ax.text(x + 0.3, y + 2.5, name, fontsize=9.5, weight="bold", color=ec)
ax.text(x + 0.3, y + 1.55, how, fontsize=8.2, color=INK, va="center", linespacing=1.5)
box(ax, x + 0.3, y + 0.25, 5.0, 0.85, tradeoff, fc="white", ec=ec, fs=7.8)
fig.tight_layout(); fig.savefig(OUT + "mlops08_rollout.png"); plt.close(fig)
print("Đã lưu Hình 1–5 và Hình 8 vào " + OUT)