Mốc 1: pipeline dữ liệu và đặc trưng có kiểm thử
Xem thửBình nhận mốc 1 và làm rất nhanh. Ngoài các đặc trưng có sẵn, anh thêm một đặc trưng nghe rất hợp lý: customer_mean_minutes, thời gian giao trung bình các đơn của cùng khách hàng. Khách ở hẻm sâu thì đơn nào cũng lâu, nên đặc trưng này "phải có ích". Mô hình Ridge trên validation (tháng 5) cải thiện từ MAE 10,46 xuống 10,30 phút.
Minh xem kết quả và chỉ ra một con số khác: nếu biết chính xác quy luật sinh dữ liệu trong data card, chỉ còn thiếu phần nhiễu ngẫu nhiên, thì MAE tốt nhất có thể đạt trên tháng 5 là 10,39 phút. Mô hình của Bình đang tốt hơn cả người biết trước quy luật.
Trước khi đọc tiếp, hãy dự đoán: đặc trưng của Bình sai ở đâu, và bạn sẽ viết một test như thế nào để máy tự bắt lỗi này trong mọi lần sửa code sau, kể cả khi không ai nhớ tới mức sàn 10,39?
Sau bài này, bạn có thể
- Viết hàm
cleansửa năm loại lỗi của file thô, mỗi lỗi có con số trong báo cáo chất lượng dữ liệu. - Tách train, validation, test theo thời gian và tạo đặc trưng chỉ từ thông tin có lúc đặt đơn.
- Viết test chống rò rỉ: đặc trưng không đổi khi nhãn đổi, và đặc trưng của đơn cũ không đổi khi có thêm đơn mới.
Cần biết trước: bài Dữ liệu thiếu, ngoại lai và lỗi nhập liệu và bài Data leakage: mô hình đẹp trên giấy, hỏng khi chạy thật (Chương 6); bài Chia dữ liệu theo thời gian và cross-validation đúng cách (Chương 9); bài pytest, logging và cấu trúc dự án cho code ML (Chương 2); bài Đề bài, yêu cầu và tiêu chí đánh giá (bài trước).
Mức độ: Trung bình · Thời gian: khoảng 30 phút · Từ khóa: làm sạch dữ liệu, time split, đặc trưng, data leakage, pytest
Mốc 1 phải giao gì
| Sản phẩm | Nội dung | Kiểm bằng |
|---|---|---|
anhti_eta/data.py | load_raw, clean (trả về dữ liệu sạch và báo cáo), time_split | tests/test_data.py, 8 test |
anhti_eta/features.py | build_features, danh sách FEATURES, TARGET_COLUMNS | tests/test_features.py, 5 test |
anhti_eta/config.py | Đọc eta.toml và biến môi trường | dùng trong conftest.py |
Mô hình tư duy của mốc này: mỗi đặc trưng phải trả lời được câu hỏi "lúc khách bấm đặt đơn, hệ thống đã biết giá trị này chưa?". Nếu câu trả lời là "chưa", đặc trưng đó là rò rỉ, dù nó làm metric đẹp đến đâu.
Làm sạch: mỗi lỗi một quy tắc, mỗi quy tắc một con số
Data card của Chương 1 liệt kê các lỗi được cố ý đưa vào orders_raw.csv. Việc của clean là sửa từng lỗi theo một quy tắc rõ ràng và đếm số dòng bị ảnh hưởng. Con số đếm được quan trọng không kém dữ liệu sạch: nếu tháng sau số dòng trùng tăng từ 500 lên 5.000, đó là tín hiệu hệ thống nguồn có vấn đề.
Sơ đồ sau là thứ tự các quy tắc cho một dòng thô. Câu hỏi cần trả lời khi đọc: dòng nào bị bỏ, dòng nào được sửa rồi giữ lại?
Đang tải sơ đồ…
Cách đọc hình: mỗi ô cam là một câu hỏi về dòng đang xét. Chỉ có một nhánh dẫn tới ô đỏ (bỏ dòng): dòng trùng order_id. Mọi lỗi khác được sửa (ô tím) rồi dòng được giữ (ô xanh lá). Lưu ý hai kiểm tra quãng đường nối tiếp nhau: một dòng vừa âm vừa gõ theo mét (ví dụ -4.200) đi qua cả hai ô tím và thành 4,2 km. Tên quận được xử lý trước khi vào sơ đồ: bỏ khoảng trắng, đưa về đúng một trong 8 tên chuẩn.
Vì sao cân nặng thiếu được giữ là NaN thay vì điền trung bình? Chương 6 đã nói: thiếu dữ liệu cũng là thông tin. HistGradientBoosting xử lý NaN sẵn, còn mô hình tuyến tính sẽ điền trung vị bên trong pipeline, nơi trung vị chỉ được tính trên train. Nếu điền ngay trong clean bằng trung vị của toàn bộ dữ liệu, bạn đã để tháng 6 ảnh hưởng tới dữ liệu tháng 1, một dạng rò rỉ nhỏ.
Tách theo thời gian, đặc trưng chỉ nhìn một dòng
Ba tập được tách bằng hai mốc ngày trong eta.toml: train là các đơn trước 01/05/2026, validation là tháng 5, test là tháng 6. Validation dùng để chọn mô hình và ngưỡng. Test chỉ được dùng một lần ở mốc 3, giống như "tháng tới" mà hệ thống thật sẽ gặp.
Đặc trưng của bản tham khảo đều là đặc trưng một dòng: mỗi giá trị chỉ được tính từ chính đơn đó. Từ created_at ta có giờ, thứ trong tuần, cờ giờ cao điểm (11 đến 12 giờ và 17 đến 19 giờ), cờ cuối tuần. Bốn cột phân loại (quận, thanh toán, gói dịch vụ, loại xe) được đổi thành kiểu Categorical với danh sách giá trị cố định, nên một giá trị lạ như "drone" thành NaN thay vì làm hỏng mô hình. Cột promised_minutes không nằm trong đặc trưng vì nó chỉ là bản sao của gói dịch vụ.
Animation dưới đây đi qua đường đi của dữ liệu với đúng các con số của bài. Bấm nút biến thể để thêm đặc trưng của Bình vào và xem chỗ nó làm thủng bức tường thời gian.
Đang tải Animation…
Điều animation muốn bạn thấy: tách theo thời gian chưa đủ. Bức tường giữa tháng 4 và tháng 5 chỉ có tác dụng nếu không đặc trưng nào được tính từ dữ liệu phía bên kia tường. Đặc trưng customer_mean_minutes tính trên toàn bộ dữ liệu đã mang thời gian giao tháng 5 và tháng 6 vào dòng tháng 1, và mang chính nhãn của đơn tháng 5 vào đặc trưng của nó.
Làm cùng mình
Code dưới đây gọi ba hàm của bản tham khảo theo đúng thứ tự của pipeline. Chạy từ thư mục m01/c13/examples của kho lab.
from anhti_eta.config import load_settings
from anhti_eta.data import clean, load_raw, time_split
from anhti_eta.features import build_features
settings = load_settings()
raw = load_raw(settings.raw_path)
df, report = clean(raw, settings.late_margin_minutes)
for key, value in report.items():
print(f"{key:26} {value}")
train, val, test = time_split(df, settings.val_start, settings.test_start)
for name, part in [("train", train), ("val", val), ("test", test)]:
print(f"{name:5} rows={len(part):6} from {part['created_at'].min():%Y-%m-%d} to {part['created_at'].max():%Y-%m-%d}"
f" rain={part['is_rain'].mean():.3f} late_30={part['is_late_30'].mean():.3f}")
X = build_features(train)
print("feature shape:", X.shape)
print(X.iloc[0].to_string())
| Dòng code | Máy làm gì | Vì sao |
|---|---|---|
load_settings() | Đọc cấu hình mặc định, ghi đè bằng biến môi trường | Ngày tách và đường dẫn không ghi cứng trong code |
clean(raw, ...) | Trả về hai thứ: bảng sạch và từ điển đếm lỗi | Báo cáo chất lượng là sản phẩm, không phải dòng log |
time_split(...) | Cắt theo created_at | Đánh giá giống cách mô hình được dùng |
build_features(train) | Tạo 14 cột đặc trưng | Cùng hàm sẽ được dùng ở API |
Kết quả [Đã chạy] Python 3.12, pandas 3.1, scikit-learn 1.9, CPU x86_64, ngày 04/11/2026:
rows_in 50500
duplicates_removed 500
district_fixed 3434
district_unknown_dropped 0
distance_negative_fixed 271
distance_meters_fixed 184
weight_missing_kept 1534
rows_out 50000
train rows= 33312 from 2026-01-01 to 2026-04-30 rain=0.109 late_30=0.051
val rows= 8487 from 2026-05-01 to 2026-05-31 rain=0.423 late_30=0.065
test rows= 8201 from 2026-06-01 to 2026-06-30 rain=0.636 late_30=0.067
feature shape: (33312, 14)
distance_km 1.53
items 5.0
weight_kg 4.15
order_value_vnd 454000.0
shipper_exp_months 15.0
is_rain 0.0
hour 7.0
weekday 3.0
is_peak 0.0
is_weekend 0.0
district Xom Vui
payment cod
service express
vehicle bike
Đọc kết quả: 50.500 dòng thô thành đúng 50.000 đơn sau khi bỏ 500 dòng trùng. 3.434 tên quận được sửa (viết hoa hoặc thừa khoảng trắng), 271 quãng đường âm, 184 quãng đường gõ theo mét. Cân nặng thiếu ở 1.534 dòng được giữ là NaN. Ba tập không chồng thời gian lên nhau, và tỉ lệ ngày mưa tăng dần từ 0,109 (train) lên 0,423 (validation) rồi 0,636 (test): mô hình sẽ được kiểm trên điều kiện khác lúc học. Đơn đầu tiên là AT027075, đơn bạn đã gặp ở Chương 3: 1,53 km, 5 món, quận Xom Vui, đặt lúc 7 giờ sáng thứ Năm.
Bản tham khảo có thêm một test so dữ liệu sau khi làm sạch với file orders.parquet (bản sạch do script sinh dữ liệu ghi ra): quãng đường và tên quận phải khớp tuyệt đối trên cả 50.000 đơn. Đây là lợi thế của dữ liệu mô phỏng: ta có đáp án để kiểm hàm làm sạch.
Tình huống thất bại: đặc trưng rò rỉ trông như một cải tiến
Quay lại đặc trưng của Bình. Đoạn code sau so ba phiên bản: chỉ đặc trưng lúc đặt đơn, thêm trung bình theo khách tính trên mọi đơn (cách của Bình), và thêm trung bình theo khách chỉ từ các đơn trước đó. Sau đó nó làm phép thử của test chống rò rỉ: sửa thời gian giao của mọi đơn tháng 5 và tháng 6, rồi đếm xem bao nhiêu dòng train bị đổi đặc trưng.
def leaky_features(frame):
X = build_features(frame)
# sai: trung bình theo khách tính trên mọi đơn, kể cả chính đơn này và các đơn tương lai
X["customer_mean_minutes"] = frame.groupby("customer_id")["delivery_minutes"].transform("mean")
return X
def train_rows_changed_by_future(builder):
future = df["created_at"] >= settings.val_start
edited = df.assign(delivery_minutes=df["delivery_minutes"].where(~future, df["delivery_minutes"] + 100))
before, after = builder(df).iloc[:, -1].fillna(-1), builder(edited).iloc[:, -1].fillna(-1)
return int((before != after)[~future].sum()), int((~future).sum())
Kết quả [Đã chạy] Python 3.12, pandas 3.1, scikit-learn 1.9, CPU x86_64, ngày 04/11/2026:
ridge, order-time features only : val MAE 10.46
ridge, + leaky customer mean : val MAE 10.30
ridge, + past-only customer mean: val MAE 10.46
train rows whose customer_mean_minutes changes when May-June delivery times change:
leaky builder : 28904 of 33312
past-only builder: 0 of 33312
Ba điều rút ra:
- Đặc trưng của Bình làm MAE validation giảm xuống 10,30, thấp hơn mức sàn 10,39. Con số "tốt hơn mức có thể" là dấu hiệu rò rỉ rõ nhất. Nguyên nhân: trung bình theo khách của một đơn tháng 5 có chứa chính thời gian giao của đơn đó, tức có chứa đáp án.
- Phiên bản chỉ dùng đơn trước đó không rò rỉ, và cũng không giúp gì (vẫn 10,46). Trong dữ liệu mô phỏng, thời gian giao không phụ thuộc khách hàng, nên đặc trưng trung thực không có tín hiệu. Đây là câu trả lời đúng: đặc trưng không có ích thì bỏ.
- Phép thử bằng code bắt được lỗi mà không cần biết mức sàn: 28.904 trên 33.312 dòng train đổi giá trị khi dữ liệu tương lai đổi. Phiên bản trung thực đổi 0 dòng.
Phép thử cuối chính là test test_features_of_past_orders_ignore_future_orders của mốc này, viết ở dạng tổng quát cho mọi đặc trưng.
Starter code và test của mốc 1
Tạo các file dưới đây trong m01/project-eta/. File cấu hình dùng chung cho cả dự án, lưu thành starter/anhti_eta/config.py (đầy đủ, không có việc cần làm):
import os
import tomllib
from dataclasses import dataclass, fields, replace
from pathlib import Path
PROJECT_DIR = Path(__file__).resolve().parents[1]
DEFAULT_DATA_DIR = Path(__file__).resolve().parents[3] / "data"
@dataclass(frozen=True)
class Settings:
data_dir: Path = DEFAULT_DATA_DIR
artifacts_dir: Path = PROJECT_DIR / "artifacts"
raw_file: str = "orders_raw.csv"
val_start: str = "2026-05-01"
test_start: str = "2026-06-01"
late_margin_minutes: int = 30
seed: int = 42
cost_call_vnd: int = 4_000
cost_missed_vnd: int = 60_000
bootstrap_rounds: int = 1_000
@property
def raw_path(self) -> Path:
return self.data_dir / self.raw_file
@property
def model_path(self) -> Path:
return self.artifacts_dir / "model.joblib"
def load_settings(path: str | Path | None = None, **overrides) -> Settings:
values: dict = {}
if path is not None and Path(path).exists():
values.update(tomllib.loads(Path(path).read_text(encoding="utf-8")))
if "ANHTI_DATA" in os.environ:
values["data_dir"] = os.environ["ANHTI_DATA"]
if "ANHTI_ARTIFACTS" in os.environ:
values["artifacts_dir"] = os.environ["ANHTI_ARTIFACTS"]
values.update(overrides)
known = {f.name for f in fields(Settings)}
unknown = set(values) - known
if unknown:
raise ValueError(f"unknown settings: {sorted(unknown)}")
for key in ("data_dir", "artifacts_dir"):
if key in values:
values[key] = Path(values[key])
return replace(Settings(), **values)
Lưu thành starter/anhti_eta/data.py:
from pathlib import Path
import numpy as np
import pandas as pd
DISTRICTS = ("Dau Pho", "Xom Vui", "Cho Moi", "Ben Song", "Doi Thong", "Cau Go", "Bai Bien", "Khu Cong Nghiep")
PROMISED_MINUTES = {"express": 60, "standard": 120}
MAX_DISTANCE_KM = 100.0
def load_raw(path: str | Path) -> pd.DataFrame:
raw = pd.read_csv(path)
raw["created_at"] = pd.to_datetime(raw["created_at"], format="%Y-%m-%d %H:%M:%S")
raw["note"] = raw["note"].fillna("")
return raw
def clean(raw: pd.DataFrame, late_margin_minutes: int = 30) -> tuple[pd.DataFrame, dict]:
df = raw.copy()
report = {"rows_in": len(df)}
# TODO 1: bỏ dòng trùng order_id (giữ dòng đầu), ghi report["duplicates_removed"].
# TODO 2: chuẩn hóa district (bỏ khoảng trắng, đưa về đúng tên trong DISTRICTS), ghi report["district_fixed"]
# (số dòng có giá trị bị đổi) và report["district_unknown_dropped"] (số dòng không nhận ra, bị bỏ).
# TODO 3: distance_km âm thì lấy trị tuyệt đối (report["distance_negative_fixed"]); lớn hơn MAX_DISTANCE_KM
# là mét gõ nhầm thành km, chia 1000 (report["distance_meters_fixed"]); làm tròn 2 chữ số.
# TODO 4: giữ weight_kg thiếu là NaN, đếm vào report["weight_missing_kept"].
# TODO 5: thêm promised_minutes theo service và is_late_30 = delivery_minutes > promised_minutes + late_margin_minutes.
# TODO 6: sắp xếp theo created_at rồi order_id, reset_index, ghi report["rows_out"].
raise NotImplementedError
def time_split(df: pd.DataFrame, val_start: str, test_start: str) -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]:
# TODO 7: báo ValueError nếu val_start không trước test_start.
# train: created_at < val_start; val: từ val_start tới trước test_start; test: từ test_start trở đi.
raise NotImplementedError
Lưu thành starter/anhti_eta/features.py:
import pandas as pd
from .data import DISTRICTS
CATEGORIES = {
"district": list(DISTRICTS),
"payment": ["cod", "prepaid"],
"service": ["express", "standard"],
"vehicle": ["bike", "motorbike", "van"],
}
INPUT_COLUMNS = ["created_at", "district", "distance_km", "items", "weight_kg", "order_value_vnd",
"payment", "service", "vehicle", "shipper_exp_months", "is_rain"]
NUMERIC = ["distance_km", "items", "weight_kg", "order_value_vnd", "shipper_exp_months", "is_rain",
"hour", "weekday", "is_peak", "is_weekend"]
CATEGORICAL = list(CATEGORIES)
FEATURES = NUMERIC + CATEGORICAL
TARGET_COLUMNS = ("delivery_minutes", "is_late", "is_late_30")
DTYPES = {col: pd.CategoricalDtype(cats) for col, cats in CATEGORIES.items()}
def build_features(df: pd.DataFrame) -> pd.DataFrame:
# TODO 8: báo KeyError nếu thiếu cột trong INPUT_COLUMNS, rồi chỉ đọc các cột đó.
# TODO 9: cột số đổi sang float; is_rain thành 0.0 hoặc 1.0.
# TODO 10: từ created_at tạo hour, weekday (0 là thứ Hai), is_peak (11-12h hoặc 17-19h), is_weekend.
# TODO 11: cột phân loại thành pd.Categorical với dtype cố định trong DTYPES (giá trị lạ thành NaN).
# Trả về DataFrame có đúng thứ tự cột FEATURES.
raise NotImplementedError
Lưu thành tests/conftest.py (sáu dòng thô tự dựng có đủ các lỗi, cùng các fixture dùng chung cho mọi mốc):
import pandas as pd
import pytest
from anhti_eta.config import load_settings
from anhti_eta.data import clean, load_raw, time_split
def tiny_raw() -> pd.DataFrame:
return pd.DataFrame({
"order_id": ["A1", "A2", "A2", "A3", "A4", "A5"],
"created_at": pd.to_datetime(["2026-04-30 11:05", "2026-05-02 08:00", "2026-05-02 08:00",
"2026-06-06 18:30", "2026-01-03 09:00", "2026-06-01 00:00"]),
"customer_id": ["C1", "C2", "C2", "C3", "C4", "C5"],
"district": ["Xom Vui", "XOM VUI", "XOM VUI", "Cho Moi ", "Dau Pho", "Bai Bien"],
"distance_km": [2.5, -3.1, -3.1, 4200.0, 1.0, 7.25],
"items": [1, 2, 2, 3, 1, 4],
"weight_kg": [1.2, None, None, 3.0, 0.5, 2.0],
"order_value_vnd": [100000, 200000, 200000, 300000, 50000, 120000],
"payment": ["cod", "prepaid", "prepaid", "cod", "cod", "prepaid"],
"service": ["express", "standard", "standard", "express", "standard", "express"],
"vehicle": ["bike", "motorbike", "motorbike", "van", "bike", "motorbike"],
"shipper_exp_months": [3, 12, 12, 30, 0, 8],
"is_rain": [False, True, True, True, False, False],
"note": ["", "giao gap", "giao gap", "", "", ""],
"promised_minutes": [60, 120, 120, 60, 120, 60],
"delivery_minutes": [95.0, 150.0, 150.0, 85.0, 40.0, 91.0],
"is_late": [True, True, True, True, False, True],
})
@pytest.fixture
def raw_small() -> pd.DataFrame:
return tiny_raw()
@pytest.fixture(scope="session")
def settings():
return load_settings()
@pytest.fixture(scope="session")
def splits(settings):
df, _ = clean(load_raw(settings.raw_path), settings.late_margin_minutes)
return time_split(df, settings.val_start, settings.test_start)
@pytest.fixture(scope="session")
def bundle(splits, settings):
from anhti_eta.train import fit_final # chỉ cần từ mốc 2 trở đi
train, val, _ = splits
b = fit_final(train, val, settings, "ridge", "logistic")
b["baseline_minutes"] = float(train["delivery_minutes"].median())
b["version"] = "eta-test"
return b
Lưu thành tests/test_data.py:
import pandas as pd
import pytest
from anhti_eta.data import DISTRICTS, clean, time_split
def test_clean_fixes_known_issues(raw_small):
df, report = clean(raw_small)
assert len(df) == 5
assert report["duplicates_removed"] == 1
assert report["district_fixed"] == 2
assert report["distance_negative_fixed"] == 1
assert report["distance_meters_fixed"] == 1
assert report["weight_missing_kept"] == 1
assert set(df["district"]) <= set(DISTRICTS)
by_id = df.set_index("order_id")
assert by_id.loc["A2", "distance_km"] == pytest.approx(3.1)
assert by_id.loc["A3", "distance_km"] == pytest.approx(4.2)
assert pd.isna(by_id.loc["A2", "weight_kg"])
def test_clean_does_not_modify_input(raw_small):
before = raw_small.copy()
clean(raw_small)
pd.testing.assert_frame_equal(raw_small, before)
def test_late_30_uses_promise_plus_margin(raw_small):
df, _ = clean(raw_small, late_margin_minutes=30)
flags = df.set_index("order_id")["is_late_30"].to_dict()
assert flags == {"A1": True, "A2": False, "A3": False, "A4": False, "A5": True}
def test_clean_output_sorted_by_time(raw_small):
df, _ = clean(raw_small)
assert df["created_at"].is_monotonic_increasing
def test_time_split_by_date_boundaries(raw_small):
df, _ = clean(raw_small)
train, val, test = time_split(df, "2026-05-01", "2026-06-01")
assert list(train["order_id"]) == ["A4", "A1"]
assert list(val["order_id"]) == ["A2"]
assert list(test["order_id"]) == ["A5", "A3"]
def test_time_split_rejects_reversed_dates(raw_small):
df, _ = clean(raw_small)
with pytest.raises(ValueError):
time_split(df, "2026-06-01", "2026-05-01")
def test_real_data_split_has_no_time_overlap(splits):
train, val, test = splits
assert len(train) + len(val) + len(test) == 50_000
assert train["created_at"].max() < val["created_at"].min()
assert val["created_at"].max() < test["created_at"].min()
def test_real_raw_cleans_back_to_reference_distances(splits, settings):
cleaned = pd.concat(splits)
reference = pd.read_parquet(settings.data_dir / "orders.parquet")
merged = cleaned.merge(reference, on="order_id", suffixes=("", "_ref"))
assert len(merged) == 50_000
assert (merged["distance_km"] - merged["distance_km_ref"]).abs().max() < 1e-9
assert (merged["district"] == merged["district_ref"]).all()
Lưu thành tests/test_features.py:
import numpy as np
import pandas as pd
from anhti_eta.data import clean
from anhti_eta.features import FEATURES, TARGET_COLUMNS, build_features
def test_features_never_include_targets():
assert not set(FEATURES) & set(TARGET_COLUMNS)
assert "promised_minutes" not in FEATURES
def test_features_ignore_target_values(raw_small):
df, _ = clean(raw_small)
scrambled = df.copy()
scrambled["delivery_minutes"] = 999.0
scrambled["is_late"] = ~scrambled["is_late"]
scrambled["is_late_30"] = ~scrambled["is_late_30"]
pd.testing.assert_frame_equal(build_features(df), build_features(scrambled))
def test_features_of_past_orders_ignore_future_orders(splits):
train, val, _ = splits
window = pd.concat([train.tail(300), val.head(300)]).reset_index(drop=True)
past_only = build_features(window.head(300))
with_future = build_features(window).head(300)
pd.testing.assert_frame_equal(past_only, with_future)
def test_time_features_from_created_at(raw_small):
df, _ = clean(raw_small)
X = build_features(df).set_index(df["order_id"])
assert X.loc["A1", "hour"] == 11 and X.loc["A1", "is_peak"] == 1
assert X.loc["A3", "hour"] == 18 and X.loc["A3", "is_peak"] == 1
assert X.loc["A3", "is_weekend"] == 1
assert X.loc["A4", "is_peak"] == 0
def test_unknown_category_becomes_missing(raw_small):
df, _ = clean(raw_small)
df.loc[0, "vehicle"] = "drone"
X = build_features(df)
assert pd.isna(X.loc[0, "vehicle"])
assert list(X.columns) == FEATURES
assert np.isnan(X.loc[X.index[df["order_id"] == "A2"][0], "weight_kg"])
Nhiệm vụ
- Hoàn thành các mục
# TODO 1:đến# TODO 7:trongdata.py. Mỗi bước sửa lỗi ghi một khóa vàoreport. - Hoàn thành các mục
# TODO 8:đến# TODO 11:trongfeatures.py. Không đọc cột nào ngoàiINPUT_COLUMNS. - Chạy test của mốc 1 tới khi 13 test qua.
- Viết một script nhỏ in báo cáo làm sạch và kích thước ba tập (như phần Làm cùng mình), lưu kết quả vào README của bạn.
- Thử thêm đặc trưng
customer_mean_minuteskiểu của Bình vàobuild_features, chạy lại test, ghi lại test nào trượt và thông báo lỗi, rồi xóa đặc trưng đó đi.
cd m01/project-eta
PYTHONPATH=starter uv run pytest -q tests/test_data.py tests/test_features.py
Kết quả mong đợi
13 passed. Hai test dùng dữ liệu thật (test_real_data_split_has_no_time_overlap,test_real_raw_cleans_back_to_reference_distances) mất vài giây vì đọc 50.500 dòng.- Báo cáo làm sạch đúng các con số: 500 dòng trùng, 3.434 tên quận sửa, 271 quãng đường âm, 184 quãng đường gõ theo mét, 1.534 cân nặng thiếu, còn 50.000 dòng.
- Ba tập có 33.312, 8.487 và 8.201 đơn.
- Ở nhiệm vụ 5, đặc trưng kiểu của Bình làm trượt ba test. Hai test chống rò rỉ:
test_features_ignore_target_values(các đơn tự dựng đều thuộc những khách khác nhau, nên trung bình theo khách chính là nhãn của đơn) vàtest_features_of_past_orders_ignore_future_orders. Test thứ ba,test_unknown_category_becomes_missing, trượt vì danh sách cột không còn khớpFEATURES. Nếu bạn đổi sang phiên bản chỉ dùng đơn trước đó, hai test chống rò rỉ qua, nhưng nhớ hiểu lầm 3 bên dưới: qua test chưa có nghĩa là đúng hoàn toàn.
Hiểu lầm thường gặp
Hiểu lầm 1: "Tách theo thời gian rồi thì không thể rò rỉ." Tách chỉ chia dòng. Nếu một đặc trưng được tính trên toàn bộ bảng trước khi tách (trung bình theo khách, trung bình theo quận, chuẩn hóa toàn cục), thông tin tương lai đã nằm sẵn trong từng dòng. Thứ tự đúng: tách trước, mọi phép "học từ dữ liệu" chỉ chạy trên train.
Hiểu lầm 2: "Metric validation càng thấp càng tốt." Có một giới hạn dưới do nhiễu. Trên dữ liệu này, mức sàn là 10,39 phút. Mô hình vượt qua mức sàn không phải giỏi, mà đang nhìn thấy đáp án. Với dữ liệu thật, không ai biết mức sàn, nên phải dựa vào test chống rò rỉ.
Hiểu lầm 3: "Đặc trưng lịch sử dùng shift(1) là an toàn tuyệt đối." shift(1) lấy đơn liền trước của khách, nhưng đơn đó có thể được đặt 5 phút trước và chưa giao xong, nên thời gian giao của nó chưa có lúc dự đoán. Đặc trưng lịch sử đúng phải lọc theo thời điểm giao xong, không theo thứ tự đặt.
| Triệu chứng | Nguyên nhân | Cách kiểm chứng | Cách sửa |
|---|---|---|---|
| MAE validation thấp hơn mức có thể, tụt mạnh khi chạy thật | Đặc trưng chứa nhãn hoặc dữ liệu tương lai | Sửa nhãn tương lai, đếm dòng train đổi đặc trưng | Tính đặc trưng chỉ từ thông tin trước thời điểm đặt đơn |
test_real_raw_cleans_back_to_reference_distances trượt, sai lệch hàng nghìn km | Kiểm tra "lớn hơn 100" chạy trước khi lấy trị tuyệt đối, nên 2 dòng vừa âm vừa gõ theo mét bị bỏ sót | In các dòng có quãng đường nhỏ hơn -100 | Lấy trị tuyệt đối trước, rồi mới chia 1000 |
| Số dòng sau làm sạch là 49.500 hoặc 50.500 | Bỏ cả hai bản của dòng trùng, hoặc không bỏ dòng nào | So report["duplicates_removed"] với 500 | duplicated(subset="order_id", keep="first") |
district_fixed bằng 0 | So sánh sau khi đã gán cột mới | In vài giá trị trước và sau | Tính số dòng đổi trước khi gán |
Tự kiểm tra (có đáp án)
Câu 1 (giải thích). Vì sao test_features_of_past_orders_ignore_future_orders dựng đặc trưng hai lần (300 đơn và 600 đơn) rồi so 300 dòng đầu, thay vì chỉ kiểm danh sách cột?
Đáp án: danh sách cột chỉ bắt được lỗi dùng nhầm cột nhãn. Rò rỉ kiểu trung bình theo nhóm không đổi tên cột nào, nó đổi giá trị. So giá trị của cùng các dòng cũ khi có thêm đơn mới là cách kiểm trực tiếp tính chất "không nhìn thấy tương lai".
Câu 2 (dự đoán). Nếu bạn đặt val_start = "2026-04-01" trong eta.toml, tỉ lệ ngày mưa của tập train sẽ tăng hay giảm, và vì sao điều đó có thể làm MAE validation đẹp hơn trong khi MAE tháng 6 không đổi nhiều?
Đáp án: train chỉ còn tháng 1 đến tháng 3, tỉ lệ mưa của train gần như không đổi (khoảng 0,11). Validation thành tháng 4 và 5, mưa ít hơn tháng 5 đứng riêng, nên giống train hơn và MAE validation dễ đẹp hơn. Tháng 6 vẫn mưa 63,6%, nên con số trên test mới là con số trung thực.
Câu 3 (vận dụng). Đội vận hành muốn thêm đặc trưng "số đơn đang chờ giao ở cùng quận lúc đặt đơn". Đặc trưng này có rò rỉ không, và test nào của mốc 1 phải mở rộng?
Đáp án: không rò rỉ nếu chỉ đếm các đơn đặt trước và chưa giao xong tại đúng thời điểm đặt. Nó phụ thuộc các dòng khác, nên test hiện tại (so 300 dòng đầu khi thêm dòng mới) vẫn áp dụng được; cần thêm một test nhỏ với vài đơn biết trước giờ đặt và giờ giao để kiểm cách đếm.
Điều cần nhớ
- Mỗi đặc trưng phải trả lời được: lúc đặt đơn, giá trị này đã có chưa.
- Báo cáo làm sạch là sản phẩm: 500 dòng trùng, 3.434 tên quận, 271 quãng đường âm, 184 quãng đường gõ theo mét.
- Test chống rò rỉ kiểm giá trị, không chỉ tên cột: sửa nhãn hoặc thêm đơn tương lai, đặc trưng của đơn cũ phải đứng yên.
- Bài tiếp theo là mốc 2: huấn luyện bốn mô hình hồi quy và ba mô hình phân loại trên đúng các đặc trưng này, ghi mọi thí nghiệm vào một bảng và chọn bằng validation.
Nguồn tham khảo
- scikit-learn, Common pitfalls: Data leakage: https://scikit-learn.org/stable/common_pitfalls.html
- scikit-learn, Cross-validation: TimeSeriesSplit và dữ liệu có thứ tự thời gian: https://scikit-learn.org/stable/modules/cross_validation.html
- pandas, Categorical data: https://pandas.pydata.org/docs/user_guide/categorical.html
- pytest, How to use fixtures: https://docs.pytest.org/en/stable/how-to/fixtures.html
- AI Engineering from Scratch (MIT), phase 02, ML pipelines: https://github.com/rohitg00/ai-engineering-from-scratch/tree/main/phases/02-ml-fundamentals/13-ml-pipelines