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.
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))
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á.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ại | Nghĩa | Ví 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 | Ưu | Nhượ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ột | Loạ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-fill | Tự 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)
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 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áp | Công thức / ý tưởng | Phù hợp |
|---|---|---|
| IQR | Ngoà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-score | z = (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. |
| Boxplot | Trự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)
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())
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.Univariate (đơn biến) — nhìn từng biến một để hiểu phân phối:
describe().value_counts() + bar chart để xem tần suất và mất cân bằng.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ến | Công cụ | Cho biết |
|---|---|---|
| Số ↔ Số | Scatter plot, correlation | Xu hướng tuyến tính, cụm, outlier. |
| Số ↔ Category | Boxplot / violin theo nhóm | Phân phối biến số khác nhau ra sao giữa các nhóm. |
| Category ↔ Category | Crosstab, stacked bar | Bảng chéo tần suất, quan hệ phụ thuộc. |
| Nhiều số cùng lúc | Correlation heatmap, pairplot | Ma 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"]))
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()
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"]])
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).