Mục lục
- Mục tiêu bài học
- Vì sao missing data quan trọng
- Phân loại: MCAR, MAR, MNAR
- NaN trong Pandas:
np.nan,None,pd.NA - Phát hiện NaN với
isna - Xoá NaN với
dropna - Điền NaN với
fillna ffill/bfill— forward / backward fill- Nội suy với
interpolate - Khi nào dùng cái nào
- Pitfall khi impute
- Sklearn
SimpleImputer/KNNImputer - Use case AI/ML
- Code Python tổng hợp
- Bài tập
- Tóm tắt
Mục tiêu bài học
- Phân biệt 3 loại missing data: MCAR, MAR, MNAR.
- Phát hiện NaN với
isna/isnull; tính số lượng và tỉ lệ. - Xoá NaN với
dropna(tham sốaxis,how,thresh,subset). - Điền NaN với
fillna,ffill,bfill,interpolate. - Biết chiến lược phù hợp cho từng tình huống và pitfall phổ biến.
Vì sao missing data quan trọng
Dataset thực tế gần như luôn có giá trị thiếu: user không điền form, sensor mất tín hiệu vài giây, hệ thống log bị gián đoạn, hoặc cột phái sinh chỉ tính được cho 1 tập subset. Cứ load 1 file Kaggle bất kỳ rồi gọi df.isna().sum() là sẽ thấy.
Phần lớn model ML không train trực tiếp được trên NaN: sklearn estimator sẽ raise ValueError: Input contains NaN; mạng nơ-ron sẽ ra NaN loss và "đứng" không học. Một số ít model có hỗ trợ: LightGBM / XGBoost / CatBoost xử lý NaN nội bộ, nhưng đa số pipeline vẫn cần một bước impute hoặc drop rõ ràng.
Cách xử lý sai có thể không bao giờ raise error nhưng vẫn ngầm phá metric — ví dụ fillna(0) cho cột salary khiến trung bình bị kéo xuống và model học sai phân phối.
Phân loại: MCAR, MAR, MNAR
Strategy xử lý NaN khác nhau tuỳ "tại sao dữ liệu bị thiếu". Thống kê chia làm 3 nhóm (Rubin 1976):
- MCAR — Missing Completely At Random: việc thiếu hoàn toàn ngẫu nhiên, không phụ thuộc giá trị nào — ví dụ sensor bị mất gói tin do lỗi mạng. Xoá row vẫn cho ước lượng không lệch (unbiased), chỉ giảm sample size.
- MAR — Missing At Random: việc thiếu phụ thuộc các cột khác đã quan sát được. Ví dụ phụ nữ ít khai báo "income" hơn nam giới — biết cột
genderlà có thể model hoá xác suất missing. Impute có điều kiện theo nhóm thường cho kết quả tốt. - MNAR — Missing Not At Random: việc thiếu phụ thuộc chính giá trị bị thiếu. Ví dụ người thu nhập rất cao thường từ chối khai báo thu nhập; bệnh nhân nặng bỏ giữa chừng nghiên cứu. MNAR là khó nhất: mọi imputation thuần từ dữ liệu hiện có đều có thể lệch, cần thêm thông tin domain.
Trong thực hành, bạn hiếm khi biết chắc 100% thuộc nhóm nào. Nhưng ít nhất nên hỏi: "missing này có thể phụ thuộc cột target không?" — nếu có, drop row mù quáng sẽ làm lệch model.
NaN trong Pandas: np.nan, None, pd.NA
Pandas dùng 3 giá trị để biểu diễn "thiếu":
np.nan— IEEE 754 floating point NaN. Là giá trị mặc định cho cột số có missing, dtype của cột tự bị nâng lênfloat64(doNaNchỉ tồn tại trong float).None— PythonNoneType. Dùng cho cộtobject(string, mixed type). Pandas coiNonetương đươngNaNtrongisna.pd.NA— universal missing marker giới thiệu từ Pandas 1.0, hoạt động đồng nhất trên các dtype nullable mới (Int64,boolean,string). Hiện vẫn chưa mặc định, phải opt-in qua dtype có chữ hoa:Int64thayint64,booleanthaybool.
import numpy as np
import pandas as pd
s = pd.Series([1, 2, np.nan, None])
print(s.dtype) # float64 — np.nan kéo lên float
print(s.isna().sum()) # 2 (np.nan và None đều bị coi là NaN)
# Dtype nullable mới — dùng pd.NA
s2 = pd.Series([1, 2, pd.NA], dtype="Int64")
print(s2.dtype) # Int64 (chữ I hoa)
print(s2.isna().sum())# 1
Bài này dùng np.nan mặc định cho ví dụ, vì đa số dataset từ read_csv trả về như vậy.
Phát hiện NaN với isna
Bước đầu tiên trước khi xử lý là biết NaN nằm ở đâu và bao nhiêu. isna() và isnull() là alias của nhau, cùng trả về DataFrame boolean cùng shape.
import numpy as np
import pandas as pd
df = pd.DataFrame({
"age": [25, np.nan, 30, 22, np.nan],
"salary": [1000, 1500, np.nan, np.nan, 2000],
"city": ["HN", "HCM", None, "HN", "DN"],
})
print(df.isna()) # boolean DataFrame cùng shape
print(df.isna().sum()) # đếm NaN từng cột
print(df.isna().sum().sum())# tổng số NaN toàn bảng
print(df.isna().any()) # cột nào có ít nhất 1 NaN
print(df.isna().mean()) # tỉ lệ NaN từng cột (0..1)
Một số helper hay dùng:
df.isna().any(axis=1)— boolean Series,Truecho row có ít nhất 1 NaN.df[df.isna().any(axis=1)]— chọn các row chứa NaN để inspect.df.notna()— phủ định, hữu ích khi filter row hợp lệ:df[df["age"].notna()].
Với dataset rộng, visualize NaN bằng heatmap (seaborn.heatmap(df.isna()) hoặc package missingno) giúp thấy nhanh pattern — ví dụ 1 cột thiếu toàn cụm liên tiếp, hay nhiều cột cùng thiếu trên 1 row. Phần visualization sẽ chi tiết hơn ở các bài Matplotlib/Seaborn sau.
Xoá NaN với dropna
dropna trả về DataFrame mới đã loại bỏ row (mặc định) hoặc cột chứa NaN. Các tham số quan trọng:
axis=0(mặc định) — drop ROW;axis=1— drop COLUMN.how="any"(mặc định) — drop nếu có ≥1 NaN;how="all"— chỉ drop nếu TẤT CẢ giá trị NaN.thresh=N— chỉ giữ row có ít nhấtNgiá trị non-NaN.subset=["col1", "col2"]— chỉ kiểm tra NaN trong những cột này, các cột khác bỏ qua.
import numpy as np
import pandas as pd
df = pd.DataFrame({
"a": [1, 2, np.nan, 4, np.nan],
"b": [np.nan, 2, np.nan, 4, 5],
"c": [1, np.nan, np.nan, 4, 5],
})
# 1) Drop row nào có >=1 NaN — mặc định
print(df.dropna())
# 2) Drop CỘT có NaN
print(df.dropna(axis=1))
# 3) Chỉ drop row mà TẤT CẢ ô đều NaN
print(df.dropna(how="all"))
# 4) Chỉ giữ row có ít nhất 2 giá trị non-NaN
print(df.dropna(thresh=2))
# 5) Chỉ check NaN trong cột "a" và "c"
print(df.dropna(subset=["a", "c"]))
Cảnh báo phổ biến: drop quá tay khiến sample còn lại quá ít, model không đủ data để học. Trước khi dropna() thẳng tay, luôn nhìn df.shape trước/sau và df.isna().mean() để biết tỉ lệ.
Điền NaN với fillna
fillna thay NaN bằng giá trị xác định. Có nhiều cách truyền tham số:
import numpy as np
import pandas as pd
df = pd.DataFrame({
"age": [25, np.nan, 30, 22, np.nan],
"salary": [1000.0, 1500.0, np.nan, np.nan, 2000.0],
"city": ["HN", "HCM", None, "HN", "DN"],
})
# 1) Điền MỌI NaN bằng 0 — thường KHÔNG nên dùng cho cột số có ý nghĩa
df.fillna(0)
# 2) Điền per-column với dict — phổ biến nhất
df.fillna({
"age": df["age"].median(),
"salary": df["salary"].mean(),
"city": "unknown",
})
# 3) Điền 1 cột bằng mean
df["salary"] = df["salary"].fillna(df["salary"].mean())
# 4) Median ROBUST hơn mean khi có outlier (lương vài tỉ làm mean lệch)
df["age"] = df["age"].fillna(df["age"].median())
# 5) Mode cho cột categorical — mode() trả về Series, lấy [0]
df["city"] = df["city"].fillna(df["city"].mode()[0])
Một số ghi chú:
- Mean nhạy với outlier — cột có vài giá trị cực lớn sẽ làm mean lệch; median robust hơn.
- Với cột categorical, fill bằng mode (giá trị xuất hiện nhiều nhất) hoặc bằng 1 hằng "unknown" / "missing" — tạo thành category riêng để model học được "thiếu" là 1 tín hiệu.
- Pandas 2.x khuyến nghị dùng
df["col"] = df["col"].fillna(...)thay vìdf.fillna(..., inplace=True)—inplaceđang trên đường deprecation về copy-on-write.
ffill / bfill — forward / backward fill
Trong time series, NaN thường có ý nghĩa "lúc đó chưa có data mới" — phù hợp lấy giá trị gần nhất:
df.ffill()— forward fill: điền NaN bằng giá trị non-NaN gần nhất TRƯỚC nó.df.bfill()— backward fill: điền bằng giá trị non-NaN gần nhất SAU nó.limit=N— chỉ điền tối đa N row NaN liên tiếp; quá đó để nguyên.
import numpy as np
import pandas as pd
s = pd.Series([1.0, np.nan, np.nan, np.nan, 5.0, np.nan, 7.0])
print(s.ffill())
# 0 1.0
# 1 1.0 <- copy của index 0
# 2 1.0
# 3 1.0
# 4 5.0
# 5 5.0
# 6 7.0
print(s.bfill())
# 0 1.0
# 1 5.0 <- copy giá trị sau
# 2 5.0
# 3 5.0
# 4 5.0
# 5 7.0
# 6 7.0
# Chỉ điền 1 row liên tiếp; còn lại giữ NaN
print(s.ffill(limit=1))
ffill hợp lý với dữ liệu kiểu "trạng thái còn giữ nguyên cho tới khi có cập nhật mới" (giá cổ phiếu phiên không giao dịch, tỉ giá cuối tuần). Nhưng nó không hợp với time series có xu hướng tăng/giảm mạnh — lúc đó interpolate phù hợp hơn.
Lưu ý: Pandas 2.0 đã loại bỏ tham số method="ffill" trong fillna. Dùng method dedicated df.ffill() / df.bfill().
Nội suy với interpolate
interpolate điền NaN dựa vào các giá trị xung quanh theo một hàm nội suy. Phù hợp với dữ liệu có cấu trúc liên tục (nhiệt độ, sensor, tín hiệu).
import numpy as np
import pandas as pd
s = pd.Series([1.0, np.nan, np.nan, 4.0, np.nan, 6.0])
# Mặc định: linear — nội suy tuyến tính giữa 2 điểm không NaN gần nhất
print(s.interpolate(method="linear"))
# 0 1.0
# 1 2.0 <- (1 + 4) / 3 * 1
# 2 3.0
# 3 4.0
# 4 5.0
# 5 6.0
# Với time-indexed Series: dùng method="time" để tính theo khoảng thời gian thực
dates = pd.date_range("2024-01-01", periods=6, freq="D")
ts = pd.Series([1.0, np.nan, np.nan, 4.0, np.nan, 6.0], index=dates)
print(ts.interpolate(method="time"))
# Nội suy đa thức bậc 2 — cần SciPy
print(s.interpolate(method="polynomial", order=2))
Một số method phổ biến (Pandas chuyển một phần sang SciPy nên một số method cần cài scipy):
"linear"— mặc định; coi index như khoảng cách đều. An toàn, dễ giải thích."time"— chỉ vớiDatetimeIndex; nội suy theo khoảng thời gian thực (xử lý đúng khi sample không cách đều)."polynomial",order=k— nội suy đa thức bậc k; bậc cao dễ "wiggle" ở biên."spline"— spline mượt hơn, cũng cần chọn bậc.
Use case điển hình: sensor đo nhiệt độ mỗi phút, mất tín hiệu 3 phút giữa giờ trưa → interpolate(method="time") cho ước lượng hợp lý hơn cả ffill lẫn fill bằng mean cả cột.
Khi nào dùng cái nào
Không có "right answer" cho mọi case. Một bộ heuristic làm điểm xuất phát:
- Cột < 5% NaN, numeric, MCAR:
fillna(median)(robust với outlier) — đơn giản, ít rủi ro. - Cột < 5% NaN, categorical:
fillna(mode)hoặc fill bằng category"unknown". - Time series (sensor, tỉ giá, log):
ffillnếu giá trị "còn giữ";interpolatenếu thay đổi liên tục. - Cột > 70% NaN: cân nhắc DROP cột — phần còn lại không đủ tín hiệu, impute sẽ tạo dữ liệu giả nhiều hơn dữ liệu thật.
- Row có TARGET NaN trong supervised learning: DROP row đó — không thể train hay đánh giá nếu không biết label.
- MNAR nghi ngờ: thêm 1 cột flag
was_missing(boolean) trước khi impute — cho model biết "ô này vốn thiếu". Đôi khi flag này quan trọng hơn cả giá trị impute.
Pitfall khi impute
fillna(0)cho cột số có ý nghĩa thực (salary, age, price) làm lệch trung bình và variance: 0 đôi khi không nằm trong miền giá trị hợp lệ.- Mean / median imputation làm giảm variance của cột: mọi NaN trở thành cùng 1 giá trị, distribution bị thu hẹp. Model regression sau đó có thể đánh giá thấp uncertainty.
- Data leak từ test sang train: nếu tính
meantrên TOÀN bộ dataset rồi mớitrain_test_split, thông tin từ test đã "rò" vào quá trình impute. Đúng: split trước, fit imputer trên train, transform cả train lẫn test. - Impute đè lên missing có nghĩa: nhiều khi NaN bản thân nó là tín hiệu (user không khai báo income vì income cao). Impute mean sẽ xoá tín hiệu này. Giữ thêm cột flag
income_missing. - Impute cho cột target: gần như không bao giờ nên làm trong supervised learning — bạn đang "đoán" label rồi train model học chính cái đoán đó. Drop row là an toàn hơn.
- Quên kiểm tra sau impute: gọi
df.isna().sum().sum()lại sau bước impute để chắc chắn không còn NaN nào sót — đôi khi 1 cột bị bỏ quên làm sklearn raise lỗi mãi về sau.
Sklearn SimpleImputer / KNNImputer
Khi đi vào pipeline ML, sklearn cung cấp imputer object — quan trọng vì chúng phân biệt fit (học từ train) và transform (áp lên train + test), tránh leak.
from sklearn.impute import SimpleImputer, KNNImputer
import numpy as np
import pandas as pd
df = pd.DataFrame({
"age": [25, np.nan, 30, 22, np.nan, 40],
"salary": [1000, 1500, np.nan, np.nan, 2000, 2500],
})
# 1) SimpleImputer — mean / median / most_frequent / constant
imp = SimpleImputer(strategy="median")
df_imp = pd.DataFrame(imp.fit_transform(df), columns=df.columns)
print(df_imp)
print(imp.statistics_) # [median(age), median(salary)] đã học từ data
# 2) KNNImputer — dùng K láng giềng gần nhất (theo các cột khác) để impute
knn = KNNImputer(n_neighbors=2)
df_knn = pd.DataFrame(knn.fit_transform(df), columns=df.columns)
print(df_knn)
KNNImputer chính xác hơn SimpleImputer khi các cột có tương quan (vd: age và salary), nhưng tốn nhiều bộ nhớ và chậm trên dataset lớn. Có thêm IterativeImputer dùng 1 model regression cho mỗi cột — chi tiết sẽ thuộc về series ML cổ điển.
Pattern chuẩn trong sklearn Pipeline:
from sklearn.pipeline import Pipeline
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
pipe = Pipeline([
("imputer", SimpleImputer(strategy="median")),
("scaler", StandardScaler()),
("clf", LogisticRegression()),
])
# fit học imputer + scaler + clf trên TRAIN; transform cả train và test
# pipe.fit(X_train, y_train)
# pipe.score(X_test, y_test)
Use case AI/ML
- Pipeline preprocessing chuẩn: detect (
isna) → quyết định strategy theo cột → apply (dropna/fillna/ imputer). Lặp lại như nhau cho train, val, test. - Tabular ML (Titanic, House Prices):
SimpleImputer(median)cho cột số,SimpleImputer(most_frequent)cho cột categorical, kèm flagwas_missing. - Time series forecasting: dùng
interpolate(method="time")cho gap ngắn; gap dài thì chia segment hoặc bỏ chuỗi đó. - LLM evaluation: kết quả call API có thể null do timeout — đếm tỉ lệ NaN trong metric trước khi báo cáo trung bình; tách rõ "fail rate" và "score trên sample hợp lệ".
- Sensor / IoT: NaN có ngữ nghĩa "thiết bị offline" — đôi khi nên giữ làm tín hiệu (flag), không nên silently impute.
Code Python tổng hợp
import numpy as np
import pandas as pd
# Dataset giả lập: thông tin học viên với NaN ngẫu nhiên
df = pd.DataFrame({
"id": [1, 2, 3, 4, 5, 6],
"age": [25, np.nan, 30, 22, np.nan, 40],
"salary": [1000.0, 1500.0, np.nan, np.nan, 2000.0, 2500.0],
"city": ["HN", "HCM", None, "HN", "DN", None],
"target": [1, 0, np.nan, 1, 0, 1],
})
# --- 1) Phát hiện ---
print("Tỉ lệ NaN mỗi cột:")
print(df.isna().mean().round(3))
# --- 2) Drop row mà TARGET NaN — không train được ---
df = df.dropna(subset=["target"]).reset_index(drop=True)
df["target"] = df["target"].astype(int)
# --- 3) Tạo flag "was_missing" trước khi impute ---
df["age_missing"] = df["age"].isna().astype(int)
df["salary_missing"] = df["salary"].isna().astype(int)
# --- 4) Impute per-column ---
df["age"] = df["age"].fillna(df["age"].median()) # median (robust)
df["salary"] = df["salary"].fillna(df["salary"].mean()) # mean
df["city"] = df["city"].fillna(df["city"].mode()[0]) # mode
# --- 5) Kiểm tra lại ---
assert df.isna().sum().sum() == 0
print(df)
# --- 6) Time series demo: nội suy nhiệt độ ---
dates = pd.date_range("2024-01-01", periods=7, freq="D")
temp = pd.Series(
[25.0, np.nan, np.nan, 28.0, np.nan, 27.0, 26.5],
index=dates, name="temp",
)
print("\nNội suy theo time:")
print(temp.interpolate(method="time"))
Bài tập
- Tạo DataFrame thời tiết 10 ngày với cột
date(DatetimeIndex),temp; cố tình để 3 ngày NaN ở giữa. Dùnginterpolate(method="time")điền và in kết quả. - Cho DataFrame có cột
agevới 20% NaN. Điền NaN bằngmedian. So sánhdf["age"].std()trước và sau impute — quan sát variance giảm. - Từ DataFrame có cột
targetchứa vài NaN (label của bài toán binary classification), drop tất cả row cótargetNaN bằngdropna(subset=["target"]); sau đóastype("int")cho cột target. - Tính tỉ lệ NaN từng cột với
df.isna().mean(). Drop những cột có tỉ lệ > 0.5 bằng cách:cols = df.columns[df.isna().mean() > 0.5]rồidf.drop(columns=cols). - (Mở rộng) Trước khi impute cột
income(nghi MNAR), thêm cộtincome_was_missing = df["income"].isna().astype(int). Train 1 model với và 1 model không có flag, so sánh metric — kiểm tra flag có giúp ích không.
Tóm tắt
- 3 loại missing: MCAR (ngẫu nhiên hoàn toàn), MAR (phụ thuộc cột khác), MNAR (phụ thuộc giá trị bị thiếu). Strategy khác nhau theo từng nhóm.
- Pandas dùng
np.nan(float),None(object), vàpd.NA(dtype nullable mới) cho missing. - Phát hiện:
isna,isna().sum(),isna().mean(). - Xoá:
dropna(axis, how, thresh, subset)— cẩn thận không drop quá tay. - Điền:
fillna(0 / mean / median / mode / dict per-column),ffill/bfillcho "trạng thái còn giữ",interpolatecho time series liên tục. - Pipeline ML dùng
SimpleImputer/KNNImputer— fit trên train, transform cho cả train và test, tránh leak. - Cân nhắc giữ cờ
was_missingkhi nghi MNAR, drop row khi target NaN, drop column khi > 70% NaN.
- Pandas User Guide - Working with missing data
- Pandas Docs - DataFrame.isna
- Pandas Docs - DataFrame.dropna
- Pandas Docs - DataFrame.fillna
- Pandas Docs - DataFrame.ffill
- Pandas Docs - DataFrame.bfill
- Pandas Docs - DataFrame.interpolate
- scikit-learn User Guide - Imputation of missing values
- scikit-learn Docs - SimpleImputer
- scikit-learn Docs - KNNImputer
- Rubin (1976) - Inference and Missing Data, Biometrika 63(3)
