Stratified split hay Group split? Tránh rò rỉ dữ liệu khi chia tập học máy

Stratified split hay Group split? Tránh rò rỉ dữ liệu khi chia tập học máy

Stratified split chia dữ liệu sao cho tỷ lệ các nhãn gần giống nhau giữa train và test. Group split giữ các bản ghi của cùng một đối tượng ở cùng một phía của phép chia. Khi cần đánh giá khả năng dự đoán cho người, thiết bị hoặc tài liệu hoàn toàn mới, hãy chọn ranh giới nhóm phù hợp trước, rồi xét việc giữ tỷ lệ nhãn.

Trong thí nghiệm dưới đây, mô hình 3-NN đạt balanced accuracy 99,95% khi chia ngẫu nhiên từng dòng và giữ tỷ lệ nhãn. Trên 100 nhóm chưa từng xuất hiện trong train, điểm của chính các mô hình đó chỉ còn 50,65%. Các dòng test trong phép chia đầu tiên mang đặc trưng của những đối tượng mô hình đã gặp khi học.

Một dòng mới hay một đối tượng mới?

Giả sử mỗi thiết bị tạo nhiều bản ghi đo. Bạn có thể muốn dự đoán bản ghi tiếp theo của một thiết bị đã theo dõi, hoặc muốn đem mô hình tới một thiết bị chưa từng xuất hiện trong dữ liệu huấn luyện. Hai mục tiêu đòi hỏi cách đánh giá khác nhau.

Với mục tiêu thứ hai, toàn bộ bản ghi của một thiết bị cần nằm ở cùng phía train hoặc test. Nếu dữ liệu gồm nhiều ảnh chụp cùng một người, nhiều cửa sổ cắt từ một tín hiệu, hoặc nhiều đoạn lấy từ một tài liệu, hãy xác định đối tượng gốc dùng làm nhóm. Đặc trưng nhận diện đối tượng vẫn có thể tồn tại trong dữ liệu sau khi bỏ cột ID.

Với mục tiêu dự đoán tương lai của đối tượng đã biết, cần giữ thứ tự thời gian và chỉ dùng thông tin có sẵn trước thời điểm dự đoán. Chia ngẫu nhiên theo dòng có thể đặt bản ghi tương lai vào train và bản ghi quá khứ vào test. Ranh giới thời gian và ranh giới đối tượng cần được thiết kế theo cách mô hình sẽ được dùng.

Ba phép chia trong scikit-learn

Phép chia Cách sử dụng trong bài Điều cần kiểm tra
train_test_split(..., stratify=y) Chia ngẫu nhiên các dòng theo nhãn Cùng một đối tượng có thể xuất hiện ở cả hai phía.
GroupShuffleSplit Chọn ngẫu nhiên nhóm cho test, giữ nguyên các dòng trong mỗi nhóm Tỷ lệ nhãn và tỷ lệ số dòng phụ thuộc vào các nhóm được chọn.
StratifiedGroupKFold Giữ nhóm nguyên vẹn và cố gắng cân bằng tỷ lệ nhãn giữa các fold Ràng buộc nhóm có thể khiến tỷ lệ nhãn lệch khỏi mong muốn.

stratify=y dùng nhãn để phân tầng các dòng. Muốn giữ nguyên đối tượng, cần truyền mảng groups cho một splitter hỗ trợ nhóm. Trong bài này, groups[i] là mã đối tượng của dòng thứ i. Tài liệu train_test_split

Với GroupShuffleSplit(test_size=0.2), 20% được tính theo số nhóm. Nếu một nhóm có 10 dòng và nhóm khác có 10.000 dòng, tỷ lệ số dòng test có thể khác xa 20%. Các tập test ở những lượt chia khác nhau của splitter này cũng có thể giao nhau. Tài liệu GroupShuffleSplit

StratifiedGroupKFold cố giữ tỷ lệ nhãn trong giới hạn các nhóm cho phép. Mỗi nhóm xuất hiện ở test đúng một lần qua toàn bộ các fold. Nếu tất cả mẫu dương chỉ thuộc về một nhóm, bạn không thể đưa mẫu dương vào cả năm fold test mà vẫn giữ nhóm nguyên vẹn. Tài liệu StratifiedGroupKFold

Phân tầng khi chia train/test dùng thông tin nhãn để thiết kế phép đánh giá. Bạn có thể đối chiếu với các phương pháp lấy mẫu khảo sát như phân tầng và lấy mẫu cụm, nơi mục tiêu là chọn một phần quần thể để ước lượng đặc tính của quần thể. Ở đây, câu hỏi là mô hình học được từ train có dự đoán tốt cho các đối tượng dành riêng cho test hay không.

Tạo dữ liệu có nhiều bản ghi cùng đối tượng

Thí nghiệm tạo 300 đối tượng, mỗi đối tượng có 12 bản ghi, tổng cộng 3.600 dòng. Có đúng 30% đối tượng mang nhãn 1. Mỗi đối tượng giữ nguyên nhãn trên cả 12 bản ghi.

Mỗi dòng có 13 đặc trưng. Một đặc trưng mang tín hiệu phân lớp yếu, được sinh theo 1.2 * y - 0.6 + nhiễu, với nhiễu Gauss có độ lệch chuẩn 1,3. Mười hai đặc trưng còn lại hình thành một dấu hiệu nhận diện ngẫu nhiên của đối tượng. Chúng được lấy độc lập với nhãn, nhưng gần như giữ nguyên trên các bản ghi của cùng đối tượng, chỉ cộng nhiễu nhỏ có độ lệch chuẩn 0,05.

Các dấu hiệu này giúp những dòng cùng nhóm nằm gần nhau trong không gian đặc trưng. Mô hình lân cận có thể tìm thấy một bản ghi quen thuộc cùng đối tượng trong train rồi lấy nhãn của nó. Khi gặp đối tượng mới, dấu hiệu nhận diện được sinh ngẫu nhiên lại, và nhãn của người hàng xóm thuộc nhóm cũ có thể khác.

Một tập đối chiếu riêng gồm 100 đối tượng mới, tổng cộng 1.200 dòng, được tạo với seed khác và cùng cơ chế sinh dữ liệu. Tập này có 30% nhãn dương, không dùng để fit bộ chuẩn hóa, huấn luyện hay chọn siêu tham số.

Giữ kích thước train/test ngang nhau

Cả ba phương pháp đều tạo 2.880 dòng train và 720 dòng test trong mỗi lượt của thí nghiệm. Tất cả nhóm có cùng 12 dòng, nên chọn 20% nhóm cũng cho đúng 20% số dòng.

Phép chia theo dòng được chạy với năm seed cố định 11, 22, 33, 44 và 55. GroupShuffleSplit tạo năm lượt với random_state=2026. StratifiedGroupKFold chạy đủ năm fold với shuffle=False trên bộ dữ liệu cố định. Đây là năm phép đánh giá cho mỗi phương pháp, với cơ chế chọn test như mô tả ở trên.

Bảng sau ghi các tỷ lệ quan sát được qua năm lượt. “Nhóm chung” là số mã đối tượng có mặt đồng thời trong train và test của cùng một lượt.

Phương pháp Dòng train/test mỗi lượt Nhãn dương train Nhãn dương test Nhóm chung
Stratified theo dòng 2.880 / 720 30,0% 30,0% 273–285
GroupShuffleSplit 2.880 / 720 30,4–32,1% 21,7–28,3% 0
StratifiedGroupKFold 2.880 / 720 30,0% 30,0% 0

Phân tầng giữ tỷ lệ nhãn rất đẹp trong phép chia đầu tiên, nhưng vẫn có hàng trăm đối tượng xuất hiện ở cả hai phía. GroupShuffleSplit tách được đối tượng và làm tỷ lệ nhãn thay đổi theo lượt. Trên bộ dữ liệu có nhóm đều nhau này, StratifiedGroupKFold đạt cả hai yêu cầu.

Điểm số đổi thế nào khi gặp nhóm mới?

Hai mô hình được giữ cấu hình cố định cho toàn bộ thí nghiệm. Mô hình 3-NN dự đoán theo ba hàng xóm gần nhất. Logistic Regression cung cấp một mô hình tuyến tính để đối chiếu. Mỗi mô hình nằm trong Pipeline với StandardScaler, và pipeline chỉ được fit trên train của lượt tương ứng.

Chỉ số dùng ở đây là balanced accuracy, bằng trung bình recall của từng lớp. Với bài toán nhị phân, nó cho hai lớp trọng số ngang nhau dù số dòng mỗi lớp khác nhau. Tài liệu balanced_accuracy_score

Bảng lấy trung bình điểm qua năm lượt hoặc năm fold. Cột cuối đánh giá từng mô hình đã fit trên cùng tập đối chiếu 100 nhóm mới.

Phương pháp chia Mô hình Balanced accuracy trên test của phép chia Balanced accuracy trên 100 nhóm mới
Stratified theo dòng 3-NN 99,95% 50,65%
Stratified theo dòng Logistic Regression 64,22% 61,81%
GroupShuffleSplit 3-NN 49,71% 49,13%
GroupShuffleSplit Logistic Regression 60,57% 61,24%
StratifiedGroupKFold 3-NN 52,88% 51,19%
StratifiedGroupKFold Logistic Regression 60,65% 61,75%

Nếu chọn mô hình bằng test chia theo dòng, 3-NN có vẻ vượt xa Logistic Regression. Khi đánh giá trên đối tượng mới, thứ tự đảo lại. 3-NN tận dụng rất tốt các dòng tương tự của nhóm đã biết, còn Logistic Regression khai thác được một phần tín hiệu phân lớp có thể chuyển sang nhóm mới.

Hai phép chia theo nhóm cho kết quả gần tập đối chiếu hơn đối với cả hai mô hình. Chúng giúp phát hiện khoảng cách giữa điểm trên những bản ghi của đối tượng quen thuộc và điểm trên đối tượng hoàn toàn mới. Năm phép chia dùng lại cùng bộ dữ liệu, vì vậy bảng chỉ mô tả thí nghiệm tổng hợp này. Mức chênh vài điểm phần trăm giữa hai splitter theo nhóm chưa đủ để chọn một phương pháp cho mọi bộ dữ liệu.

Kiểm tra ranh giới trước khi chọn mô hình

Sau khi tạo chỉ số train/test, mã kiểm tra giao của hai tập chỉ số và giao của hai tập mã nhóm. Các lượt theo nhóm phải có số nhóm chung bằng 0. Đây là điều có thể kiểm tra trực tiếp thay vì dựa vào tên splitter.

Bộ chuẩn hóa, phép chọn đặc trưng hoặc phép cân bằng lại dữ liệu cũng cần được fit trong phần train của từng fold. Pipeline giúp giữ thứ tự xử lý này khi đánh giá nhiều lượt. Hướng dẫn scikit-learn về rò rỉ dữ liệu

Với dữ liệu thực, hãy báo cả số dòng, số nhóm, tỷ lệ nhãn và ranh giới thời gian của mỗi tập. Nếu số bản ghi mỗi đối tượng chênh nhiều, điểm tính trên toàn bộ dòng sẽ dành trọng số lớn hơn cho những đối tượng có nhiều bản ghi. Có thể tính thêm điểm theo từng đối tượng nếu mục tiêu là chất lượng trung bình trên đối tượng mới.

Mã tái lập đầy đủ

Mã được chạy với Python 3.10.11, NumPy 2.2.5 và scikit-learn 1.6.1. Lưu thành experiment.py rồi chạy python3 experiment.py. Bộ sinh dữ liệu dùng seed 2026 cho tập 300 nhóm và 2027 cho tập 100 nhóm mới. Chương trình in từng lượt, tỷ lệ nhãn, số nhóm chung và điểm trung bình cuối cùng.

 1import platform
 2
 3import numpy as np
 4import sklearn
 5from sklearn.base import clone
 6from sklearn.linear_model import LogisticRegression
 7from sklearn.metrics import balanced_accuracy_score
 8from sklearn.model_selection import GroupShuffleSplit, StratifiedGroupKFold, train_test_split
 9from sklearn.neighbors import KNeighborsClassifier
10from sklearn.pipeline import make_pipeline
11from sklearn.preprocessing import StandardScaler
12
13
14def make_records(n_groups, seed, group_offset=0):
15    rng = np.random.default_rng(seed)
16    group_labels = np.zeros(n_groups, dtype=int)
17    group_labels[: n_groups * 3 // 10] = 1
18    rng.shuffle(group_labels)
19    local_groups = np.repeat(np.arange(n_groups), 12)
20    y = group_labels[local_groups]
21    fingerprints = rng.normal(size=(n_groups, 12))
22    stable_features = fingerprints[local_groups] + rng.normal(
23        scale=0.05, size=(len(y), 12)
24    )
25    signal = 1.2 * y - 0.6 + rng.normal(scale=1.3, size=len(y))
26    X = np.column_stack([signal, stable_features])
27    return X, y, local_groups + group_offset
28
29
30def make_splits(X, y, groups):
31    indices = np.arange(len(y))
32    row_splits = [
33        train_test_split(indices, test_size=0.2, stratify=y, random_state=seed)
34        for seed in (11, 22, 33, 44, 55)
35    ]
36    group_splits = GroupShuffleSplit(n_splits=5, test_size=0.2, random_state=2026)
37    stratified_groups = StratifiedGroupKFold(n_splits=5, shuffle=False)
38    return {
39        "Stratified": row_splits,
40        "GroupShuffle": list(group_splits.split(X, y, groups)),
41        "StratifiedGroup": list(stratified_groups.split(X, y, groups)),
42    }
43
44
45def main():
46    X, y, groups = make_records(300, 2026)
47    X_new, y_new, groups_new = make_records(100, 2027, group_offset=300)
48    assert not np.intersect1d(groups, groups_new).size
49    estimators = {
50        "3NN": make_pipeline(StandardScaler(), KNeighborsClassifier(n_neighbors=3)),
51        "Logistic": make_pipeline(StandardScaler(), LogisticRegression(max_iter=1000)),
52    }
53    results = {}
54    print(f"Python {platform.python_version()}, NumPy {np.__version__}, "
55          f"scikit-learn {sklearn.__version__}")
56    print(f"development: {len(y)} rows, {len(np.unique(groups))} groups, positive {y.mean():.1%}")
57    print(f"external: {len(y_new)} rows, {len(np.unique(groups_new))} groups, positive {y_new.mean():.1%}")
58    print("split method            rows_train rows_test positive_train positive_test shared_groups model     test_BA external_BA")
59
60    for name, splits in make_splits(X, y, groups).items():
61        for split_number, (train, test) in enumerate(splits, start=1):
62            shared = np.intersect1d(groups[train], groups[test]).size
63            assert not np.intersect1d(train, test).size
64            assert len(train) + len(test) == len(y)
65            if name != "Stratified":
66                assert shared == 0
67            for model_name, estimator in estimators.items():
68                model = clone(estimator).fit(X[train], y[train])
69                test_score = balanced_accuracy_score(y[test], model.predict(X[test]))
70                new_score = balanced_accuracy_score(y_new, model.predict(X_new))
71                results.setdefault((name, model_name), []).append([test_score, new_score])
72                print(f"{split_number:>4}  {name:<17} {len(train):>5}     {len(test):>5}      "
73                      f"{y[train].mean():>6.1%}        {y[test].mean():>6.1%}        "
74                      f"{shared:>3}      {model_name:<8} {test_score:>7.4f} {new_score:>10.4f}")
75
76    print("means over 5 splits/folds")
77    for (name, model_name), scores in results.items():
78        average = np.mean(scores, axis=0)
79        print(f"{name:<17} {model_name:<8} {average[0]:.4f} {average[1]:.4f}")
80
81
82if __name__ == "__main__":
83    main()

Đọc thêm trên blog

Bình luận