Bài 5

EDA & Làm sạch dữ liệu

Trước khi mô hình hoá, bạn phải hiểu dữ liệu và làm sạch nó. Bài này đi qua toàn bộ quy trình Exploratory Data Analysis (EDA): kiểm tra tổng quan, xử lý missing values, outlier, dữ liệu trùng và không nhất quán, phân tích đơn biến/song biến, trực quan hoá với matplotlib & seaborn, và cách biến đổi phân phối lệch — kèm code pandas/sklearn chạy được.

EDA là gì & mục tiêu
Làm quen dữ liệu trước khi làm bất cứ điều gì

Exploratory Data Analysis (EDA) là bước khám phá dữ liệu bằng thống kê mô tả và biểu đồ để hiểu cấu trúc, phát hiện lỗi, tìm quan hệ và hình thành giả thuyết — trước khi xây feature hay train model. Khái niệm do John Tukey đặt nền móng: "để dữ liệu tự nói".

Mục tiêu chính của EDA:

Checklist đầu vào — 5 câu lệnh đầu tiên với mọi DataFrame:

import pandas as pd
import numpy as np

df = pd.read_csv("train.csv")

# 1. Kích thước: (số dòng, số cột)
print(df.shape)

# 2. Kiểu dữ liệu + số non-null + bộ nhớ
df.info()

# 3. Thống kê mô tả các cột số (count, mean, std, min, quartiles, max)
print(df.describe())
# Với cả cột object/category:
print(df.describe(include="all"))

# 4. Số giá trị duy nhất mỗi cột -> nhận diện ID, categorical, hằng số
print(df.nunique().sort_values())

# 5. Xem nhanh vài dòng đầu/cuối/ngẫu nhiên
print(df.head())
print(df.sample(5, random_state=42))
Đọc nhanh: cột có nunique bằng số dòng thường là ID (bỏ khỏi model); nunique = 1 là hằng số (vô dụng); nunique nhỏ trên cột số có thể thực chất là categorical được mã hoá.
Missing values — dữ liệu thiếu
Phân loại, phát hiện & chiến lược xử lý

Giá trị thiếu (NaN, None, NaT) gần như luôn xuất hiện. Cách xử lý đúng phụ thuộc vào cơ chế sinh ra thiếu (missingness mechanism):

LoạiNghĩaVí dụ
MCAR
(Missing Completely At Random)
Thiếu hoàn toàn ngẫu nhiên, không liên quan gì đến dữ liệu. Bỏ đi không gây thiên lệch.Cảm biến rớt gói tin ngẫu nhiên.
MAR
(Missing At Random)
Xác suất thiếu phụ thuộc vào các biến quan sát được khác, không phụ thuộc chính giá trị bị thiếu.Nam giới ít điền cân nặng hơn — thiếu phụ thuộc cột "giới tính".
MNAR
(Missing Not At Random)
Xác suất thiếu phụ thuộc vào chính giá trị bị thiếu. Nguy hiểm nhất, khó xử lý đúng.Người thu nhập cao từ chối khai thu nhập.

Phát hiện missing:

# Số lượng & tỉ lệ thiếu mỗi cột
miss = df.isna().sum()
miss_pct = (df.isna().mean() * 100).round(2)
report = pd.DataFrame({"n_missing": miss, "pct": miss_pct})
print(report[report.n_missing > 0].sort_values("pct", ascending=False))

# Trực quan pattern thiếu (cần: pip install missingno)
import missingno as msno
msno.matrix(df)      # thấy các cột thiếu cùng nhau không
msno.heatmap(df)     # tương quan giữa các pattern thiếu

Chiến lược xử lý — so sánh ưu/nhược:

Chiến lượcƯuNhược / Khi dùng
Drop dòng (dropna)Đơn giản, không bịa dữ liệu.Mất mẫu; chỉ dùng khi thiếu ít (<5%) & là MCAR.
Drop cộtLoại cột hỏng nặng.Chỉ khi cột thiếu quá nhiều (>60–70%) & ít giá trị.
Mean / Median (số)Nhanh, giữ nguyên số mẫu.Giảm phương sai, bóp méo phân phối. Median chịu outlier tốt hơn mean.
Mode (category)Hợp cho dữ liệu phân loại.Làm lệch về giá trị phổ biến; có thể thêm hạng "Unknown".
Forward/Back-fillTự nhiên cho time series (giữ giá trị gần nhất).Sai nếu dữ liệu không có thứ tự thời gian.
Model-based (KNN, Iterative/MICE)Chính xác nhất, tận dụng tương quan giữa các cột.Tốn tài nguyên, dễ rò rỉ nếu fit sai chỗ; cần cẩn thận với train/test.
from sklearn.impute import SimpleImputer, KNNImputer
from sklearn.experimental import enable_iterative_imputer  # bật MICE
from sklearn.impute import IterativeImputer

# Median cho cột số, most_frequent cho cột category
num_cols = df.select_dtypes(include="number").columns
cat_cols = df.select_dtypes(include="object").columns

num_imputer = SimpleImputer(strategy="median")
cat_imputer = SimpleImputer(strategy="most_frequent")

df[num_cols] = num_imputer.fit_transform(df[num_cols])
df[cat_cols] = cat_imputer.fit_transform(df[cat_cols])

# Nâng cao: KNN dựa trên các mẫu lân cận
knn = KNNImputer(n_neighbors=5)
df[num_cols] = knn.fit_transform(df[num_cols])

# Mẹo: thêm cờ "đã từng thiếu" — đôi khi bản thân việc thiếu là tín hiệu
df["age_was_missing"] = df["age"].isna().astype(int)
Rò rỉ dữ liệu (leakage): LUÔN tính giá trị impute (mean/median/mode/KNN) chỉ trên tập train rồi áp lên test — dùng fit trên train, transform trên test. Nếu impute trên toàn bộ dữ liệu trước khi split, thông tin từ test rò vào train, làm điểm đánh giá cao ảo. Xem thêm phần cuối bài.
Outlier — giá trị ngoại lai
Phát hiện, xử lý & khi nào KHÔNG nên xoá

Outlier là giá trị nằm cách xa bất thường so với phần còn lại. Chúng có thể là lỗi nhập liệu (tuổi = 999) hoặc tín hiệu thật (giao dịch gian lận, khách VIP). Đừng xoá vội.

Phát hiện bằng 3 cách phổ biến:

Phương phápCông thức / ý tưởngPhù hợp
IQRNgoài khoảng [Q1 − 1.5·IQR, Q3 + 1.5·IQR] với IQR = Q3 − Q1.Phân phối lệch, không cần giả định chuẩn. Bền vững.
Z-scorez = (x − mean) / std; |z| > 3 coi là outlier.Chỉ hợp phân phối gần chuẩn; nhạy với chính outlier.
BoxplotTrực quan hoá IQR — điểm ngoài "râu" là outlier.Kiểm tra mắt nhanh, so sánh theo nhóm.
# --- IQR ---
Q1 = df["income"].quantile(0.25)
Q3 = df["income"].quantile(0.75)
IQR = Q3 - Q1
low, high = Q1 - 1.5 * IQR, Q3 + 1.5 * IQR
outliers = df[(df["income"] < low) | (df["income"] > high)]
print(f"Số outlier: {len(outliers)}")

# --- Z-score ---
from scipy import stats
z = np.abs(stats.zscore(df["income"].dropna()))
print((z > 3).sum())

Xử lý — chọn theo bản chất outlier:

# Winsorize: kẹp về percentile 1% và 99%
lo, hi = df["income"].quantile([0.01, 0.99])
df["income_capped"] = df["income"].clip(lower=lo, upper=hi)
Khi nào KHÔNG nên xoá: khi outlier là mục tiêu bài toán (phát hiện gian lận, phát hiện bất thường), khi dữ liệu vốn có đuôi dài tự nhiên (thu nhập, giá nhà), hoặc khi số outlier lớn — xoá sẽ bóp méo phân phối và làm model không tổng quát được ra thực tế.
Trùng lặp, sai kiểu & không nhất quán
Chuẩn hoá để dữ liệu "sạch" thật sự

Dữ liệu trùng làm model học lệch (mẫu bị đếm nhiều lần) và thổi phồng điểm đánh giá:

# Đếm & xoá dòng trùng hoàn toàn
print(df.duplicated().sum())
df = df.drop_duplicates()

# Trùng theo khoá nghiệp vụ (vd cùng email) -> giữ bản mới nhất
df = df.sort_values("updated_at").drop_duplicates(
    subset=["email"], keep="last")

Sai kiểu dữ liệu — số bị đọc thành chuỗi, ngày thành text:

# Ép kiểu số (giá trị lỗi -> NaN thay vì crash)
df["price"] = pd.to_numeric(df["price"], errors="coerce")

# Ép kiểu ngày tháng
df["order_date"] = pd.to_datetime(df["order_date"], errors="coerce")

# Chuyển cột lặp lại nhiều thành category (tiết kiệm bộ nhớ)
df["city"] = df["city"].astype("category")

Chuẩn hoá text / category và xử lý giá trị không nhất quán — "HN", "Hà Nội", "ha noi" phải gộp làm một:

# Bỏ khoảng trắng thừa + đưa về chữ thường
df["city"] = df["city"].str.strip().str.lower()

# Map các biến thể về giá trị chuẩn
city_map = {
    "hn": "ha noi", "hanoi": "ha noi",
    "hcm": "ho chi minh", "tphcm": "ho chi minh",
}
df["city"] = df["city"].replace(city_map)

# Chuẩn hoá boolean / yes-no
df["active"] = df["active"].map(
    {"yes": 1, "no": 0, "y": 1, "n": 0})

print(df["city"].value_counts())
Mẹo: luôn chạy df["col"].value_counts(dropna=False) trên mọi cột category — nó phơi bày ngay các biến thể chính tả, khoảng trắng ẩn và giá trị lạ như "?", "N/A", "-" mà pandas không tự nhận là NaN.
Phân tích đơn biến & song biến
Univariate & Bivariate analysis

Univariate (đơn biến) — nhìn từng biến một để hiểu phân phối:

Bivariate (song biến) — quan hệ giữa hai biến, đặc biệt với biến mục tiêu:

Cặp biếnCông cụCho biết
Số ↔ SốScatter plot, correlationXu hướng tuyến tính, cụm, outlier.
Số ↔ CategoryBoxplot / violin theo nhómPhân phối biến số khác nhau ra sao giữa các nhóm.
Category ↔ CategoryCrosstab, stacked barBảng chéo tần suất, quan hệ phụ thuộc.
Nhiều số cùng lúcCorrelation heatmap, pairplotMa trận tương quan, phát hiện đa cộng tuyến.
# Tương quan Pearson giữa các cột số
corr = df.select_dtypes(include="number").corr()
print(corr["target"].sort_values(ascending=False))  # biến nào liên quan target nhất

# Bảng chéo hai biến category
print(pd.crosstab(df["gender"], df["churn"], normalize="index"))

# Thống kê biến số theo nhóm
print(df.groupby("city")["income"].agg(["mean", "median", "std"]))
Tương quan cao ≠ nhân quả. Ngoài ra, hai biến tương quan mạnh với nhau (đa cộng tuyến) có thể gây bất ổn cho các mô hình tuyến tính — cân nhắc bỏ bớt một hoặc gộp lại ở bước feature engineering (Bài 6).
Trực quan hoá với matplotlib & seaborn
Code ví dụ đầy đủ

matplotlib là thư viện vẽ nền tảng; seaborn xây trên matplotlib, cú pháp gọn hơn và đẹp hơn cho dữ liệu thống kê. Bộ biểu đồ EDA cốt lõi:

import matplotlib.pyplot as plt
import seaborn as sns
sns.set_theme(style="whitegrid")

# 1) Histogram + đường mật độ (KDE) — xem phân phối một biến số
sns.histplot(df["income"], kde=True, bins=40)
plt.title("Phân phối thu nhập")
plt.show()

# 2) Boxplot theo nhóm — so sánh income giữa các thành phố
sns.boxplot(data=df, x="city", y="income")
plt.xticks(rotation=45)
plt.show()

# 3) Correlation heatmap — ma trận tương quan các cột số
corr = df.select_dtypes(include="number").corr()
plt.figure(figsize=(10, 8))
sns.heatmap(corr, annot=True, fmt=".2f", cmap="coolwarm",
            center=0, square=True)
plt.title("Correlation heatmap")
plt.show()

# 4) Pairplot — scatter mọi cặp biến + histogram trên đường chéo
sns.pairplot(df, vars=["income", "age", "score"],
             hue="churn", diag_kind="hist")
plt.show()

# 5) Countplot — tần suất một biến category (kèm màu theo target)
sns.countplot(data=df, x="city", hue="churn")
plt.show()
Chọn biểu đồ: phân phối một biến → histplot; so sánh nhóm → boxplot/violinplot; quan hệ hai biến số → scatterplot; toàn cảnh tương quan → heatmap; khám phá nhiều cặp cùng lúc → pairplot (chỉ nên với <6 biến vì rất tốn tính toán).
Phân phối lệch & biến đổi
Đọc skewness & log transform

Nhiều biến thực tế (thu nhập, giá, dân số) lệch phải (right/positive skew): đa số giá trị nhỏ, một số ít rất lớn kéo đuôi. Điều này gây khó cho các mô hình giả định phân phối chuẩn và làm outlier lấn át.

# Đo độ lệch (skewness): 0 = cân xứng, >0 lệch phải, <0 lệch trái
print(df["income"].skew())      # vd 2.8 -> lệch phải mạnh

# |skew| > ~1 thường nên cân nhắc biến đổi

Biến đổi log nén đuôi phải, đưa phân phối gần chuẩn hơn — rất hữu ích trước khi train mô hình tuyến tính:

# log1p = log(1 + x): an toàn với giá trị 0, chỉ dùng khi x >= 0
df["income_log"] = np.log1p(df["income"])
print(df["income_log"].skew())   # giảm mạnh, vd còn 0.2

# So sánh trước/sau bằng biểu đồ
fig, ax = plt.subplots(1, 2, figsize=(12, 4))
sns.histplot(df["income"], kde=True, ax=ax[0]).set_title("Gốc (lệch phải)")
sns.histplot(df["income_log"], kde=True, ax=ax[1]).set_title("Sau log1p")
plt.show()

Các biến đổi khác: sqrt (lệch nhẹ), Box-Cox (chỉ cho x > 0) và Yeo-Johnson (chấp nhận cả số âm) — có sẵn trong sklearn qua PowerTransformer.

from sklearn.preprocessing import PowerTransformer
pt = PowerTransformer(method="yeo-johnson")
df["income_yj"] = pt.fit_transform(df[["income"]])
Nhắc lại về leakage: khi log/Box-Cox không có tham số học từ dữ liệu (như np.log1p) thì an toàn áp cho cả bộ. Nhưng PowerTransformer, StandardScaler, imputer... đều học tham số từ dữ liệu — phải fit trên train rồi transform test. Đóng gói mọi bước này vào một Pipeline của sklearn để không bao giờ rò rỉ (chi tiết ở Bài 6).
← Quay lại
Bài 4: SQL cho DS