Xây mô hình Elo dự đoán esports: theo ván, Bo3/Bo5, đổi người và cái bẫy mà backtest không thấy
Mình là người làm GGCombat, một trang lịch thi đấu và bảng xếp hạng Elo cho Liên Minh Huyền Thoại, Dota 2, Mobile Legends và Liên Quân. Lõi của site là một mô hình Elo tính lại toàn bộ 3 lần mỗi ngày từ mọi kết quả kể từ năm 2024, hiện là 27.535 trận. Bài này ghi lại quá trình biến công thức Elo của cờ vua thành thứ dự đoán esports tạm ổn: thêm gì, vì sao, đo thế nào, và chỗ nào mình suýt tự lừa mình. Code là TypeScript viết lại gọn cho dễ đọc. Mọi con số lấy từ dữ liệu production ngày 07.10.2026.
Vì sao Elo cờ vua áp thẳng vào esports cho kết quả kém
Elo được thiết kế cho cờ vua: hai người, một ván, người chơi ở mọi nơi đều có thể gặp nhau qua các giải mở. Esports phá gần hết các giả định đó.
- Đấu theo loạt. Trận chuyên nghiệp là Bo1, Bo3 hoặc Bo5. Chỉ ghi "thắng loạt" thì 2-0 và 2-1 như nhau. Tệ hơn, cùng một chênh lệch sức mạnh, đội mạnh thắng Bo5 dễ hơn thắng Bo1, nên một con số phần trăm cho mọi thể thức là sai.
- Đội không phải một người. Đội thay tuyển thủ giữa mùa, sang mùa có khi chỉ còn giữ cái tên.
- Các giải khu vực là những hòn đảo. Đội LCK đá với đội LCK gần như cả năm, chỉ gặp LPL hay LEC ở vài giải quốc tế. So giữa các đảo thì rất ít dữ liệu.
- Nghỉ dài. Giữa hai mùa đội có thể nghỉ nhiều tuần rồi quay lại với đội hình khác.
Bản đầu tiên của mình là Elo kiểu sách giáo khoa, tính theo loạt. Trên 1.203 trận kiểm tra, nó cho log-loss 0,6283 và đoán đúng 65,2%. Đoán đúng 65% nghe không tệ, nhưng log-loss mới cho biết mô hình có tự tin đúng chỗ hay không: một đồng xu luôn nói 50% có log-loss khoảng 0,693, nên bản đầu chỉ hơn đồng xu một khoảng khiêm tốn. Phần còn lại của bài là từng bước kéo con số đó xuống.
Bước 1: xác suất thắng một ván
Phần này giữ nguyên như cờ vua. Gọi , là điểm của hai đội, xác suất A thắng MỘT ván là:
/** Xác suất A thắng một ván. */
export const gameWinProb = (ra: number, rb: number): number =>
1 / (1 + 10 ** ((rb - ra) / 400));
Số 400 chỉ là thang đo: chênh 400 điểm nghĩa là khả năng thắng của đội mạnh gấp 10 lần khả năng thua.
Bước 2: cập nhật theo ván, không theo loạt
Thay vì hỏi "A có thắng loạt không", mình hỏi "A thắng bao nhiêu ván so với kỳ vọng". Loạt có n ván đã đấu thì kỳ vọng A thắng n × p ván, và điểm của A thay đổi:
Đội B nhận mức thay đổi ngược dấu. Ví dụ minh hoạ với K = 10 và hai đội ngang điểm (p = 0,5): thắng 2-0 thì A được 10 × (2 − 1) = +10; thắng 2-1 chỉ được 10 × (2 − 1,5) = +5. Hệ quả thú vị: đội yếu thua 1-2 trước đội mạnh hơn hẳn vẫn có thể được cộng điểm, vì lấy được một ván đã là tốt hơn kỳ vọng. Với Elo theo loạt, đội đó chỉ có bị trừ.
Bước 3: từ xác suất ván sang xác suất loạt
Người xem cần biết "ai thắng cả trận", nên phải đổi p thành xác suất thắng loạt. Giả sử các ván độc lập, cùng xác suất p. Loạt BoN kết thúc khi một bên thắng đủ ván. A thắng loạt khi giành ván thắng thứ need lúc B mới có k ván (k từ 0 tới need − 1); số cách xếp k ván thua vào need − 1 + k ván trước đó là , nên:
/** Xác suất A thắng cả loạt BoN khi xác suất thắng mỗi ván là p. */
export function seriesWinProb(p: number, bestOf: number): number {
if (bestOf <= 1) return p;
const need = Math.floor(bestOf / 2) + 1; // số ván cần thắng
let total = 0;
let comb = 1; // C(need - 1 + k, k), tính dồn để khỏi gọi giai thừa
for (let k = 0; k < need; k++) {
if (k > 0) comb = (comb * (need - 1 + k)) / k;
total += comb * p ** need * (1 - p) ** k;
}
return total;
}
Minh hoạ tính từ công thức, với đội thắng mỗi ván 60%:
| Thể thức | Xác suất thắng loạt |
|---|---|
| Bo1 | 60,0% |
| Bo3 | 64,8% |
| Bo5 | 68,3% |
Ví dụ thật tại thời điểm viết: Gen.G 1875 điểm, Hanwha Life Esports 1817 điểm; trong một loạt Bo5, mô hình cho Gen.G 65,1%. Đánh Bo1 thì con số thấp hơn đáng kể, nên mọi chỗ hiển thị phần trăm trên site đều phải truyền đúng BoN của trận.
Giả định "các ván độc lập" chỉ đúng một phần: đội có thể sa sút sau ván thua, bên thua ván trước thường được chọn phe. Dù vậy, trong backtest, cách này vẫn tốt hơn việc coi cả loạt như một ván.
Bước 4: K động, kéo về khi nghỉ, điểm khởi đầu theo hạng giải
Bộ tham số gốc (đang dùng cho Dota 2; LoL có bộ riêng, chọn lại bằng backtest):
- K = 10.
- Nhân 3 trong 10 trận đầu của đội, để đội mới hội tụ nhanh.
- Nhân 1,5 khi hai đội xuất phát từ hạng giải khác nhau: trận liên hạng chứa nhiều thông tin so sánh hơn.
- Đội nghỉ từ 45 ngày trở lên bị kéo nửa đường về điểm khởi đầu, vì sau quãng nghỉ dài đội thường đã đổi người.
- Bỏ trận thắng do đối thủ bỏ cuộc: không có ván nào thật sự diễn ra.
- Điểm khởi đầu theo hạng giải (S, A, B, C, D), thay vì cho tất cả cùng một mức.
interface TeamState {
rating: number;
played: number; // số trận đã tính
lastAt: number | null; // thời điểm trận gần nhất (ms)
seed: number; // điểm khởi đầu theo hạng giải
tier: number; // hạng giải xuất phát: 0 = S ... 4 = D
}
const K = 10;
const NEW_TEAM_MULT = 3, NEW_TEAM_GAMES = 10;
const CROSS_TIER_MULT = 1.5;
const DECAY_DAYS = 45, DECAY_FRAC = 0.5;
const DAY_MS = 86_400_000;
function applyDecay(t: TeamState, at: number) {
if (t.lastAt !== null && at - t.lastAt >= DECAY_DAYS * DAY_MS) {
t.rating += DECAY_FRAC * (t.seed - t.rating);
}
}
/** Trả về xác suất A thắng loạt TRƯỚC trận (để chấm điểm), rồi mới cập nhật 2 đội. */
function playSeries(a: TeamState, b: TeamState, winsA: number, winsB: number, bestOf: number, at: number): number {
applyDecay(a, at);
applyDecay(b, at);
const p = gameWinProb(a.rating, b.rating);
const forecast = seriesWinProb(p, bestOf); // chốt dự đoán TRƯỚC khi cộng điểm
const surprise = winsA - (winsA + winsB) * p; // ván thắng thực tế trừ kỳ vọng
const kOf = (t: TeamState) =>
K * (t.played < NEW_TEAM_GAMES ? NEW_TEAM_MULT : 1) * (a.tier !== b.tier ? CROSS_TIER_MULT : 1);
a.rating += kOf(a) * surprise;
b.rating -= kOf(b) * surprise;
a.played++;
b.played++;
a.lastAt = b.lastAt = at;
return forecast;
}
Hai chi tiết đáng để ý. K của hai đội có thể khác nhau nên tổng điểm không được bảo toàn như Elo cờ vua; mình chấp nhận để đội mới hội tụ nhanh. Và dự đoán phải được chốt TRƯỚC khi cộng điểm trận đó, nếu không backtest sẽ đẹp giả tạo vì mô hình đã thấy kết quả.
Kết quả trên cùng 1.203 trận kiểm tra, so với Elo theo loạt ban đầu: log-loss 0,6283 → 0,6097, Brier 0,2191 → 0,2111, đoán đúng 65,2% → 67,1%.
Hai thứ mình thử mà không giúp: nhân thêm hệ số theo cách biệt tỉ số của loạt (Elo theo ván đã chứa thông tin đó), và cho điểm khởi đầu giữa các hạng chênh nhau hơn 100 điểm.
Bước 5 (riêng LoL): lớp sức mạnh giải
Vấn đề hòn đảo nặng nhất ở LoL: đội thắng đều ở giải yếu có thể có Elo cao hơn đội giỏi ở giải mạnh, vì Elo chỉ so đội với đối thủ nó từng gặp. Mình tách điểm làm hai phần:
điểm hiệu dụng = mức của giải nhà + phần riêng của đội
- Giải nhà là giải đội đá nhiều trận nhất tính tới thời điểm đó. Giải nhà đổi thì phần riêng được bù để điểm hiệu dụng không nhảy.
- Trận trong cùng giải chỉ cập nhật phần riêng, như Elo thường.
- Trận giữa hai đội khác giải nhà (MSI, CKTG, EMEA Masters...) cập nhật thêm mức của cả hai giải. Nhờ vậy một nhóm nhỏ trận quốc tế dịch chuyển điểm của mọi đội trong giải, kể cả đội không đi quốc tế.
- Giải hạng 2 (LCK CL, LDL, LFL...) được gắn vào giải hạng 1 cùng khu vực (LCK, LPL, LEC...) bằng một độ lệch, vì đội hạng 2 của các khu vực khác nhau hầu như không gặp nhau. Khi LCK được MSI kéo lên, LCK CL đi lên theo; trận liên giải của chính giải hạng 2 chỉ chỉnh độ lệch đó.
// L: mức của từng giải. Giải con lưu ĐỘ LỆCH so với giải mẹ.
const level = (league: string): number =>
parentOf[league] ? level(parentOf[league]) + L[league] : L[league];
const effective = (t: { home: string; r: number }) => level(t.home) + t.r;
// Sau mỗi trận (surprise tính như Bước 4, nhưng từ điểm hiệu dụng):
a.r += kOf(a) * surprise;
b.r -= kOf(b) * surprise;
if (a.home !== b.home) {
L[a.home] += KL * surprise; // cả giải của A "ăn" kết quả này
L[b.home] -= KL * surprise;
}
(Bản rút gọn, đã bỏ vài trường hợp biên như giải mẹ gặp chính giải con của nó.)
Kết quả LoL trên 1.333 trận kiểm tra: log-loss 0,6218 → 0,5955, đoán đúng 66,6% → 69,2%. Riêng các trận giữa hai đội khác giải nhà, đúng chỗ lớp này nhắm tới: 0,6478 → 0,6018.
Lớp này không hợp với mọi game, và đây là bài học lớn nhất của mình: cấu trúc giải đấu quyết định mô hình, không phải thể loại game.
- Dota 2 không dùng lớp giải. Đội Dota đá giải mở liên tục, "giải nhà" gần như vô nghĩa; thử thêm vào thì backtest kém đi.
- Mobile Legends dùng lớp giải nhẹ. MPL từng nước cũng là những hòn đảo, nhưng lớp mạnh như LoL làm mô hình quá tự tin ở trận liên khu vực.
- Liên Quân không học mức giải. Chỉ có 39 trận liên khu vực, quá ít để học, nên so sánh giữa đội Việt Nam, Thái Lan, Đài Loan trên bảng Liên Quân kém tin cậy.
Bước 6: theo dõi đổi người
Đội hình được lấy theo từng vòng đấu. Khi một đội bắt đầu vòng mới với đội hình khác vòng trước, mình tính "điểm theo người": người ở lại mang điểm hiện tại của đội, người mới mang điểm của đội cũ họ vừa đá, người chưa có lai lịch mang mức của giải nhà. Điểm đội bị kéo 50% về trung bình đó.
const BETA = 0.5;
/** Mức điều chỉnh khi đội vào vòng mới với đội hình khác vòng trước. */
function rosterAdjust(effNow: number, players: string[], previous: Set<string>, homeLevel: number): number {
if (players.length < 5 || players.length > 7) return 0; // danh sách bẩn, bỏ qua
const perPlayer = players.map((id) =>
previous.has(id) ? effNow : lastTeamRating.get(id) ?? homeLevel,
);
const target = perPlayer.reduce((s, x) => s + x, 0) / perPlayer.length;
return BETA * (target - effNow);
}
// Sau mỗi trận: mọi tuyển thủ trong đội hình hiện tại được ghi lastTeamRating = điểm mới của đội.
Dòng lọc < 5 || > 7 có câu chuyện riêng: nguồn dữ liệu có những vòng liệt kê gộp cả tuyển thủ đội trẻ. Trước khi lọc, mô hình tưởng Gen.G vừa nhận một loạt người mới và kéo điểm họ xuống tới hạng 12.
Kết quả: LoL log-loss 0,5956 → 0,5873, đoán đúng 69,4%. Dota 2 log-loss 0,6262 → 0,6044, đoán đúng 62,8% → 65,3%. Mình dừng ở hệ số 0,5 dù hệ số lớn hơn không tệ hơn trên tập kiểm tra, vì nó làm méo bảng xếp hạng theo đúng kiểu ở phần "cái bẫy" bên dưới: đội lớn đổi người nhiều bị kéo xuống, đội nhỏ ít dữ liệu đội hình thì đứng yên.
Bước 7: backtest chia theo thời gian
Mọi con số ở trên đến từ cùng một quy trình:
- Phát lại toàn bộ lịch sử theo thứ tự thời gian, dự đoán mỗi trận TRƯỚC khi mô hình biết kết quả.
- Lấy 365 ngày gần nhất, chia hai nửa. Nửa đầu để dò tham số. Nửa sau chỉ để chấm điểm, không bao giờ dùng để chọn.
- Không chia ngẫu nhiên kiểu k-fold: như vậy là dùng trận tháng 9 để "đoán" trận tháng 3, mô hình trông giỏi hơn thực tế.
Ba thước đo: log-loss (phạt rất nặng khi tự tin mà sai, thước đo chính để chọn tham số), Brier (bình phương sai số giữa xác suất và kết quả 1 hoặc 0; tung đồng xu được 0,25) và tỉ lệ đoán đúng (dễ hiểu nhưng thô, vì 51% và 90% được tính như nhau).
function score(preds: { p: number; winA: boolean }[]) {
let logLoss = 0, brier = 0, hits = 0;
for (const { p, winA } of preds) {
const q = Math.min(Math.max(p, 1e-6), 1 - 1e-6); // tránh log(0)
const y = winA ? 1 : 0;
logLoss += -(y * Math.log(q) + (1 - y) * Math.log(1 - q));
brier += (q - y) ** 2;
hits += (q >= 0.5) === winA ? 1 : 0;
}
const n = preds.length;
return { logLoss: logLoss / n, brier: brier / n, accuracy: hits / n };
}
Tổng hợp các bước (mỗi dòng là một lần đo riêng, trên tập kiểm tra của lần đó):
| Thay đổi | Log-loss | Đoán đúng |
|---|---|---|
| Elo theo ván + K động + kéo về khi nghỉ (1.203 trận) | 0,6283 → 0,6097 | 65,2% → 67,1% |
| LoL: thêm lớp sức mạnh giải (1.333 trận) | 0,6218 → 0,5955 | 66,6% → 69,2% |
| LoL: trận khác giải nhà | 0,6478 → 0,6018 | |
| LoL: thêm theo dõi đổi người | 0,5956 → 0,5873 | 69,4% |
| Dota 2: thêm theo dõi đổi người | 0,6262 → 0,6044 | 62,8% → 65,3% |
Cái bẫy backtest không thấy: các cụm giải không bao giờ gặp nhau
Đây là phần mình muốn người đọc nhớ nhất. Có những cụm giải không bao giờ gặp nhau: giải hạng 2 châu Âu chỉ đấu với nhau ở EMEA Masters, LEC chỉ gặp LCK và LPL ở MSI hay CKTG. Khoảng cách giữa các cụm vì thế chỉ đến từ điểm khởi đầu theo hạng, thứ mình tự đặt tay.
Ở lượt dò tham số đầu tiên, khoảng cách 50 điểm mỗi hạng là bộ cho log-loss tốt nhất. Nhưng mở bảng xếp hạng ra thì giải hạng 2 của Pháp (LFL) bị đẩy ngang LEC, còn Galions, một đội LFL, đứng hạng 2 thế giới. Backtest không phạt lỗi này vì không có trận nào giữa hai cụm để chấm. Ở chỗ không có dữ liệu, mô hình sai bao nhiêu log-loss vẫn đẹp.
Cách xử lý không có gì thông minh: sau mỗi lần đổi tham số, in bảng xếp hạng ra và soát bằng mắt. Cuối cùng mình chọn khoảng cách 150, cộng với việc neo giải hạng 2 vào giải mẹ ở Bước 5.
Bảng xếp hạng Elo LMHT của GGCombat là bảng đang chạy thật từ chính mô hình này. Tại thời điểm viết, theo Elo, T1 Academy đứng hạng 7 (1746), Galions hạng 8 (1741), Solary hạng 9 (1722), cả ba là đội giải hạng 2 đang có chuỗi thắng ở WSCI và giải nhà. Đó có thể là tín hiệu thật, cũng có thể là dư âm của cái bẫy trên; bảng để công khai để ai cũng soi được.
Kiểm chứng bằng dự đoán ghi trước trận
Backtest dù cẩn thận vẫn là nhìn lại quá khứ. Site ghi mỗi dự đoán vào DB ngay trước khi kết quả trận được cộng vào Elo. Số trong giai đoạn 14.09.2026 tới 07.10.2026:
| Game | Đoán đúng | Khi mô hình tự tin từ 65% |
|---|---|---|
| LoL | 194/295 = 65,8% | 90/122 = 73,8% |
| Dota 2 | 146/231 = 63,2% | 56/73 = 76,7% |
| Mobile Legends | 88/135 = 65,2% | 34/40 = 85,0% |
| Liên Quân | 55/94 = 58,5% | 25/34 = 73,5% |
Cột bên phải quan trọng hơn: khi mô hình nói "khá chắc", nó đúng nhiều hơn hẳn. Nhưng phải nói thật hai điều. Một, đây mới là 3 tuần, mẫu nhỏ; 34/40 của Mobile Legends chỉ cần vài trận bất ngờ là đổi khác. Hai, mô hình được nâng cấp giữa kỳ, ngày 28.09, nên bảng trộn dự đoán của hai phiên bản. Mình không tính lại số cũ bằng mô hình mới, vì như vậy là sửa quá khứ.
Từ Elo tới xác suất vô địch
Có xác suất từng loạt theo đúng BoN rồi thì xác suất vô địch chỉ là mô phỏng: chạy 20.000 lần phần còn lại của nhánh đấu, trận đã đấu dùng kết quả thật, trận chưa đấu tung theo Elo. Kiểm trên vòng Swiss EMEA Masters 2026 (32 đội): đoán đúng đội đi tiếp hay bị loại 28/32, Brier 0,10 so với 0,25 của tung đồng xu.
Giới hạn
- Elo chỉ biết kết quả, không biết tuyển thủ ốm, đội đang thử chiến thuật, hay bản cập nhật vừa đổi meta.
- Dữ liệu chỉ từ 2024.
- Khoảng cách giữa các cụm giải vẫn phụ thuộc điểm khởi đầu và một nhóm nhỏ trận quốc tế, nên thứ hạng toàn cầu kém chắc chắn hơn thứ hạng trong từng khu vực.
- Đội ít trận dao động mạnh: đội đứng đầu bảng Dota 2 lúc này, TEAM VISION, mới có 11 trận.
- Giả định các ván độc lập bỏ qua tâm lý và việc chọn phe.
- Mẫu kiểm chứng trực tiếp còn nhỏ.
Kết
Nếu bạn muốn tự làm hệ thống xếp hạng cho môn thi đấu theo loạt, thứ tự mình khuyên là: bắt đầu bằng Elo theo ván và đổi đúng sang xác suất loạt; dựng backtest chia theo thời gian trước khi thêm bất kỳ thứ gì; thêm từng lớp một và đo riêng từng lớp; sau mỗi lần đổi tham số, mở bảng xếp hạng ra đọc như một người hâm mộ khó tính. Những lỗi tệ nhất của mình đều nằm ở chỗ không có trận nào để chấm, chỉ đọc bảng bằng mắt mới thấy.
Mình sẵn lòng trả lời câu hỏi kỹ thuật trong phần bình luận.
All rights reserved