Appearance
🛠 Workshop — EDA Pipeline
Từ dataset HR Employee 1,500+ dòng → thực hiện EDA 5 bước → vẽ charts + phát hiện patterns → viết EDA report với 5 key findings + recommendations. Tất cả trong Jupyter Notebook!
🎯 Mục tiêu workshop
Sau khi hoàn thành workshop này, bạn sẽ:
- Load & overview dataset bằng
df.info(),df.describe(),df.shape— nắm cấu trúc tổng quan - Univariate analysis — vẽ histogram, KDE, bar chart cho từng biến quan trọng
- Bivariate analysis — scatter plot (salary vs experience), box plot (department vs salary)
- Correlation matrix — tính
df.corr()+ vẽ heatmap bằng Seaborn - EDA Report — viết summary 5 key findings + recommendations dạng actionable
🧰 Yêu cầu
| Yêu cầu | Chi tiết |
|---|---|
| Kiến thức | Đã học Buổi 8 (Pandas Data Cleaning) + Buổi 9 lý thuyết (EDA) |
| Công cụ | Jupyter Notebook (local) HOẶC Google Colab (online) |
| Python | Python 3.8+ |
| Thư viện | pandas, numpy, matplotlib, seaborn (Colab đã có sẵn) |
| Thời gian | 60–90 phút |
💡 Naming convention
Đặt tên notebook: HoTen_Buoi09_EDA.ipynb Chia notebook thành Markdown sections rõ ràng — tuân thủ Reproducible Analysis Best Practices đã học!
📦 Dataset: HR Employee Analytics
Mô tả
Bạn là Data Analyst tại công ty công nghệ TechVN (1,500 nhân viên). HR Director muốn hiểu patterns trong dữ liệu nhân sự: phân phối lương, ảnh hưởng kinh nghiệm đến lương, tỷ lệ nghỉ việc theo phòng ban, mối quan hệ giữa các biến. Mục tiêu: EDA report để chuẩn bị cho dự án dự đoán attrition (nhân viên nghỉ việc).
Bảng dữ liệu: hr_employees (1,500 nhân viên)
| Cột | Kiểu | Mô tả | Ghi chú |
|---|---|---|---|
employee_id | str | Mã nhân viên (unique) | "E0001" → "E1500" |
name | str | Tên nhân viên | Tên Việt |
department | str | Phòng ban | Engineering, Marketing, Sales, HR, Finance |
position | str | Vị trí | Junior, Mid, Senior, Lead, Manager |
age | int | Tuổi | 22–58 |
gender | str | Giới tính | Male, Female |
salary | float | Lương tháng (VND) | 8M–60M |
experience_years | int | Số năm kinh nghiệm | 0–25 |
tenure_years | float | Số năm làm tại TechVN | 0.5–15 |
performance_score | float | Điểm đánh giá hiệu suất (1-5) | Từ review cycle |
satisfaction_score | float | Điểm hài lòng (1-5) | Từ khảo sát nội bộ |
training_hours | int | Số giờ training/năm | 0–120 |
attrition | str | Đã nghỉ việc? | "Yes", "No" |
Tạo dataset mẫu
Copy đoạn code sau vào Cell 1 của notebook:
python
# Cell 1: Tạo HR Employee Dataset
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
# Config visualization
plt.rcParams["figure.figsize"] = (10, 6)
plt.rcParams["font.size"] = 12
sns.set_style("whitegrid")
pd.set_option("display.max_columns", None)
pd.set_option("display.float_format", "{:,.2f}".format)
np.random.seed(42)
# === TẠO DATASET HR EMPLOYEES (1,500 nhân viên) ===
n = 1500
departments = ["Engineering", "Marketing", "Sales", "HR", "Finance"]
dept_weights = [0.35, 0.20, 0.25, 0.10, 0.10] # Engineering nhiều nhất
positions = ["Junior", "Mid", "Senior", "Lead", "Manager"]
first_names = ["Nguyễn", "Trần", "Lê", "Phạm", "Hoàng", "Vũ", "Đặng", "Bùi", "Đỗ", "Ngô"]
middle_names = ["Văn", "Thị", "Hoàng", "Minh", "Đức", "Thanh", "Quốc", "Anh"]
last_names = ["An", "Bình", "Cường", "Dung", "Hà", "Khoa", "Linh",
"Mai", "Nam", "Phong", "Phúc", "Quân", "Sơn", "Tâm", "Uyên", "Vinh", "Xuân"]
data = []
for i in range(n):
dept = np.random.choice(departments, p=dept_weights)
exp = np.random.randint(0, 26)
age = max(22, min(58, exp + np.random.randint(22, 28)))
# Position dựa trên experience
if exp <= 2:
pos = "Junior"
elif exp <= 5:
pos = np.random.choice(["Junior", "Mid"], p=[0.3, 0.7])
elif exp <= 10:
pos = np.random.choice(["Mid", "Senior"], p=[0.4, 0.6])
elif exp <= 15:
pos = np.random.choice(["Senior", "Lead"], p=[0.5, 0.5])
else:
pos = np.random.choice(["Lead", "Manager"], p=[0.4, 0.6])
# Salary dựa trên dept + exp + position (với noise)
base_salary = {
"Engineering": 12_000_000,
"Finance": 11_000_000,
"Marketing": 9_500_000,
"Sales": 8_000_000,
"HR": 9_000_000,
}
pos_multiplier = {"Junior": 1.0, "Mid": 1.4, "Senior": 1.9, "Lead": 2.5, "Manager": 3.2}
salary = base_salary[dept] * pos_multiplier[pos] * (1 + exp * 0.03)
salary += np.random.normal(0, salary * 0.15) # 15% noise
salary = max(8_000_000, round(salary, -5)) # Min 8M, round to 100K
# Tenure: 0.5 đến min(exp, 15)
tenure = round(np.random.uniform(0.5, max(1, min(exp, 15))), 1)
# Performance score (1-5): slight correlation with experience
perf = round(np.clip(np.random.normal(3.2 + exp * 0.03, 0.7), 1.0, 5.0), 1)
# Satisfaction score (1-5): slightly negative corr with performance (burnout effect)
satisfaction = round(np.clip(np.random.normal(3.5 - perf * 0.1 + tenure * 0.05, 0.8), 1.0, 5.0), 1)
# Training hours
training = max(0, int(np.random.normal(40 + (5 - perf) * 10, 20)))
# Attrition: higher for low satisfaction, low tenure, Sales dept
attrition_prob = 0.15
if satisfaction < 2.5:
attrition_prob += 0.25
if tenure < 2:
attrition_prob += 0.15
if dept == "Sales":
attrition_prob += 0.10
if perf > 4.0 and satisfaction < 3.0:
attrition_prob += 0.20 # High-perf but unhappy = flight risk
attrition = "Yes" if np.random.random() < attrition_prob else "No"
fn = np.random.choice(first_names)
mn = np.random.choice(middle_names)
ln = np.random.choice(last_names)
data.append({
"employee_id": f"E{i+1:04d}",
"name": f"{fn} {mn} {ln}",
"department": dept,
"position": pos,
"age": age,
"gender": np.random.choice(["Male", "Female"], p=[0.55, 0.45]),
"salary": salary,
"experience_years": exp,
"tenure_years": tenure,
"performance_score": perf,
"satisfaction_score": satisfaction,
"training_hours": training,
"attrition": attrition,
})
df = pd.DataFrame(data)
print("✅ HR Employee Dataset đã được tạo thành công!")
print(f"📊 Shape: {df.shape[0]:,} rows × {df.shape[1]} cols")
print(f"📋 Columns: {list(df.columns)}")⚠️ Chạy Cell 1 trước!
Cell 1 tạo DataFrame df trong memory. Nếu Restart Kernel, cần chạy lại Cell 1 trước khi chạy các phần sau.
Phần 1: Data Overview — "Khám bệnh tổng quát"
Bước đầu tiên của CRISP-DM Data Understanding: collect + describe. Hiểu cấu trúc dataset trước khi vẽ bất kỳ chart nào.
Bước 1.1: Overview với info() và shape
python
# Cell 2: Data Overview — info & shape
print("=" * 60)
print("📊 HR EMPLOYEE DATASET — OVERVIEW")
print("=" * 60)
print(f"\n📐 Shape: {df.shape[0]:,} rows × {df.shape[1]} cols")
print(f"\n📋 Data Types:")
print(df.dtypes)
print()
df.info()Bước 1.2: Statistical Summary
python
# Cell 3: Describe — numeric summary
print("=" * 60)
print("📈 STATISTICAL SUMMARY — NUMERIC COLUMNS")
print("=" * 60)
print(df.describe().T.to_string())python
# Cell 4: Describe — categorical summary
print("=" * 60)
print("📋 CATEGORICAL COLUMNS SUMMARY")
print("=" * 60)
for col in df.select_dtypes(include="object").columns:
if col in ["employee_id", "name"]:
continue
print(f"\n🏷️ {col} ({df[col].nunique()} unique):")
vc = df[col].value_counts()
for val, count in vc.items():
pct = count / len(df) * 100
bar = "█" * int(pct // 2)
print(f" {val:15s} {bar} {count:>5} ({pct:.1f}%)")Bước 1.3: Missing Values & Duplicates Check
python
# Cell 5: Data Quality Check
print("=" * 60)
print("🔍 DATA QUALITY CHECK")
print("=" * 60)
# Missing values
missing = df.isnull().sum()
missing_pct = (df.isnull().sum() / len(df) * 100).round(1)
print("\n📊 Missing Values:")
print(pd.DataFrame({"count": missing, "pct": missing_pct}).to_string())
# Duplicates
print(f"\n🔁 Duplicate rows: {df.duplicated().sum()}")
print(f"🔁 Duplicate employee_id: {df['employee_id'].duplicated().sum()}")
# Shape summary
print(f"\n✅ Dataset shape: {df.shape}")
print(f"✅ No cleaning needed — dataset is ready for EDA!")💡 Tại sao phải audit trước khi EDA?
Dù dataset đã sạch (buổi 8 clean rồi), bước audit giúp bạn hiểu structure — numeric vs categorical, range, cardinality — để chọn đúng chart type cho từng biến. Không audit = vẽ histogram cho biến categorical (sai!).
Phần 2: Univariate Analysis — "Khám từng biến"
OSEMN Explore Level 1: Hiểu phân phối của từng biến quan trọng trước khi tìm relationships.
Bước 2.1: Distribution các biến numeric chính
python
# Cell 6: Univariate — Histogram + KDE cho 4 biến numeric
fig, axes = plt.subplots(2, 2, figsize=(14, 10))
# Salary distribution
sns.histplot(df["salary"], bins=30, kde=True, color="steelblue", ax=axes[0, 0])
axes[0, 0].axvline(df["salary"].median(), color="red", linestyle="--",
label=f"Median: {df['salary'].median():,.0f}")
axes[0, 0].axvline(df["salary"].mean(), color="orange", linestyle="--",
label=f"Mean: {df['salary'].mean():,.0f}")
axes[0, 0].set_title("Phân phối Salary", fontsize=13)
axes[0, 0].set_xlabel("Salary (VND)")
axes[0, 0].legend(fontsize=9)
# Age distribution
sns.histplot(df["age"], bins=20, kde=True, color="coral", ax=axes[0, 1])
axes[0, 1].axvline(df["age"].median(), color="red", linestyle="--",
label=f"Median: {df['age'].median():.0f}")
axes[0, 1].set_title("Phân phối Age", fontsize=13)
axes[0, 1].set_xlabel("Tuổi")
axes[0, 1].legend(fontsize=9)
# Experience distribution
sns.histplot(df["experience_years"], bins=25, kde=True, color="mediumseagreen", ax=axes[1, 0])
axes[1, 0].axvline(df["experience_years"].median(), color="red", linestyle="--",
label=f"Median: {df['experience_years'].median():.0f}")
axes[1, 0].set_title("Phân phối Experience", fontsize=13)
axes[1, 0].set_xlabel("Kinh nghiệm (năm)")
axes[1, 0].legend(fontsize=9)
# Tenure distribution
sns.histplot(df["tenure_years"], bins=20, kde=True, color="mediumpurple", ax=axes[1, 1])
axes[1, 1].axvline(df["tenure_years"].median(), color="red", linestyle="--",
label=f"Median: {df['tenure_years'].median():.1f}")
axes[1, 1].set_title("Phân phối Tenure", fontsize=13)
axes[1, 1].set_xlabel("Thâm niên (năm)")
axes[1, 1].legend(fontsize=9)
plt.suptitle("Univariate Analysis — Phân phối 4 biến numeric chính", fontsize=15, y=1.02)
plt.tight_layout()
plt.show()
# Print summary statistics
print("📊 Summary:")
for col in ["salary", "age", "experience_years", "tenure_years"]:
skew = df[col].skew()
direction = "right-skewed" if skew > 0.5 else "left-skewed" if skew < -0.5 else "~symmetric"
print(f" {col:20s}: mean={df[col].mean():>12,.1f} | median={df[col].median():>10,.1f} | skew={skew:>6.2f} ({direction})")Bước 2.2: Distribution các biến categorical
python
# Cell 7: Univariate — Bar chart cho categorical variables
fig, axes = plt.subplots(2, 2, figsize=(14, 10))
# Department
dept_counts = df["department"].value_counts()
sns.barplot(x=dept_counts.index, y=dept_counts.values, palette="viridis", ax=axes[0, 0])
axes[0, 0].set_title("Nhân viên theo Department", fontsize=13)
axes[0, 0].set_ylabel("Số lượng")
for i, v in enumerate(dept_counts.values):
axes[0, 0].text(i, v + 5, f"{v}", ha="center", fontsize=10)
# Position
pos_order = ["Junior", "Mid", "Senior", "Lead", "Manager"]
pos_counts = df["position"].value_counts().reindex(pos_order)
sns.barplot(x=pos_counts.index, y=pos_counts.values, palette="magma", ax=axes[0, 1])
axes[0, 1].set_title("Nhân viên theo Position", fontsize=13)
axes[0, 1].set_ylabel("Số lượng")
for i, v in enumerate(pos_counts.values):
axes[0, 1].text(i, v + 5, f"{v}", ha="center", fontsize=10)
# Attrition
attr_counts = df["attrition"].value_counts()
colors = ["#2ecc71", "#e74c3c"]
axes[1, 0].pie(attr_counts.values, labels=attr_counts.index, autopct="%1.1f%%",
colors=colors, startangle=90, textprops={"fontsize": 12})
axes[1, 0].set_title("Tỷ lệ Attrition", fontsize=13)
# Gender
gender_counts = df["gender"].value_counts()
sns.barplot(x=gender_counts.index, y=gender_counts.values, palette="coolwarm", ax=axes[1, 1])
axes[1, 1].set_title("Nhân viên theo Gender", fontsize=13)
axes[1, 1].set_ylabel("Số lượng")
for i, v in enumerate(gender_counts.values):
axes[1, 1].text(i, v + 5, f"{v}", ha="center", fontsize=10)
plt.suptitle("Univariate Analysis — Phân phối biến Categorical", fontsize=15, y=1.02)
plt.tight_layout()
plt.show()Bước 2.3: Scores Distribution
python
# Cell 8: Performance & Satisfaction scores
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
sns.histplot(df["performance_score"], bins=20, kde=True, color="darkorange", ax=axes[0])
axes[0].set_title("Phân phối Performance Score", fontsize=13)
axes[0].set_xlabel("Score (1-5)")
sns.histplot(df["satisfaction_score"], bins=20, kde=True, color="teal", ax=axes[1])
axes[1].set_title("Phân phối Satisfaction Score", fontsize=13)
axes[1].set_xlabel("Score (1-5)")
plt.tight_layout()
plt.show()
print(f"📊 Performance: mean={df['performance_score'].mean():.2f}, std={df['performance_score'].std():.2f}")
print(f"📊 Satisfaction: mean={df['satisfaction_score'].mean():.2f}, std={df['satisfaction_score'].std():.2f}")⚠️ Interpretation sau mỗi chart!
Vẽ chart xong → thêm 1 Markdown cell giải thích bạn thấy gì. Ví dụ:
"Salary phân bố right-skewed (skew = 1.5). Median = 15M < Mean = 18M → nhóm salary cao kéo mean lên. Department Engineering chiếm 35% headcount — đúng vì công ty IT."
Không có interpretation = chart vô nghĩa.
Phần 3: Bivariate Analysis — "Tìm mối quan hệ"
OSEMN Explore Level 2: Khám phá relationship giữa 2 biến — numeric vs numeric, numeric vs categorical.
Bước 3.1: Scatter Plot — Salary vs Experience
python
# Cell 9: Bivariate — Scatter: Salary vs Experience by Department
plt.figure(figsize=(12, 7))
sns.scatterplot(data=df, x="experience_years", y="salary",
hue="department", style="position", alpha=0.6, s=50)
plt.title("Salary vs Experience (theo Department & Position)", fontsize=14)
plt.xlabel("Kinh nghiệm (năm)", fontsize=12)
plt.ylabel("Lương (VND)", fontsize=12)
# Add trend line
z = np.polyfit(df["experience_years"], df["salary"], 1)
p = np.poly1d(z)
x_line = np.linspace(0, 25, 100)
plt.plot(x_line, p(x_line), "r--", alpha=0.8, linewidth=2,
label=f"Trend: salary ≈ {z[1]:,.0f} + {z[0]:,.0f} × exp")
plt.legend(bbox_to_anchor=(1.05, 1), loc="upper left", fontsize=9)
plt.tight_layout()
plt.show()
# Correlation coefficient
r = df["salary"].corr(df["experience_years"])
print(f"📊 Correlation (salary, experience): r = {r:.3f}")
print(f"📊 Trend line: salary ≈ {z[1]:,.0f} + {z[0]:,.0f} × experience_years")Bước 3.2: Box Plot — Salary by Department
python
# Cell 10: Bivariate — Box plot: Salary by Department
fig, axes = plt.subplots(1, 2, figsize=(16, 6))
# Box plot
dept_order = df.groupby("department")["salary"].median().sort_values(ascending=False).index
sns.boxplot(data=df, x="department", y="salary", order=dept_order,
palette="Set2", ax=axes[0])
axes[0].set_title("Phân phối Salary theo Department", fontsize=13)
axes[0].set_xlabel("Department")
axes[0].set_ylabel("Salary (VND)")
axes[0].tick_params(axis="x", rotation=30)
# Violin plot (more detail)
sns.violinplot(data=df, x="department", y="salary", order=dept_order,
palette="Set2", inner="quartile", ax=axes[1])
axes[1].set_title("Violin Plot — Salary theo Department", fontsize=13)
axes[1].set_xlabel("Department")
axes[1].set_ylabel("Salary (VND)")
axes[1].tick_params(axis="x", rotation=30)
plt.tight_layout()
plt.show()
# Summary statistics by department
print("📊 Salary by Department:")
salary_by_dept = df.groupby("department")["salary"].agg(["median", "mean", "std", "min", "max"])
salary_by_dept = salary_by_dept.sort_values("median", ascending=False)
print(salary_by_dept.to_string(float_format="{:,.0f}".format))Bước 3.3: Attrition Analysis
python
# Cell 11: Bivariate — Attrition by Department & Satisfaction
fig, axes = plt.subplots(1, 3, figsize=(18, 5))
# Attrition rate by department
attrition_by_dept = df.groupby("department")["attrition"].apply(
lambda x: (x == "Yes").mean() * 100
).sort_values(ascending=False)
sns.barplot(x=attrition_by_dept.index, y=attrition_by_dept.values,
palette="YlOrRd", ax=axes[0])
axes[0].set_title("Attrition Rate by Department", fontsize=13)
axes[0].set_ylabel("Attrition Rate (%)")
axes[0].tick_params(axis="x", rotation=30)
for i, v in enumerate(attrition_by_dept.values):
axes[0].text(i, v + 0.5, f"{v:.1f}%", ha="center", fontsize=10)
# Satisfaction by attrition
sns.boxplot(data=df, x="attrition", y="satisfaction_score",
palette=["#2ecc71", "#e74c3c"], ax=axes[1])
axes[1].set_title("Satisfaction Score by Attrition", fontsize=13)
axes[1].set_ylabel("Satisfaction Score")
# Performance by attrition
sns.boxplot(data=df, x="attrition", y="performance_score",
palette=["#2ecc71", "#e74c3c"], ax=axes[2])
axes[2].set_title("Performance Score by Attrition", fontsize=13)
axes[2].set_ylabel("Performance Score")
plt.tight_layout()
plt.show()
# Key metrics
print("📊 Attrition Analysis:")
print(f" Overall attrition rate: {(df['attrition'] == 'Yes').mean() * 100:.1f}%")
print(f"\n Satisfaction (Stayed): {df[df['attrition'] == 'No']['satisfaction_score'].mean():.2f}")
print(f" Satisfaction (Left): {df[df['attrition'] == 'Yes']['satisfaction_score'].mean():.2f}")
print(f"\n Performance (Stayed): {df[df['attrition'] == 'No']['performance_score'].mean():.2f}")
print(f" Performance (Left): {df[df['attrition'] == 'Yes']['performance_score'].mean():.2f}")Bước 3.4: Salary by Position & Department (Grouped)
python
# Cell 12: Bivariate — Salary by Position (grouped by Department)
plt.figure(figsize=(14, 6))
pos_order = ["Junior", "Mid", "Senior", "Lead", "Manager"]
sns.boxplot(data=df, x="position", y="salary", hue="department",
order=pos_order, palette="Set2")
plt.title("Salary by Position & Department", fontsize=14)
plt.xlabel("Position Level")
plt.ylabel("Salary (VND)")
plt.legend(title="Department", bbox_to_anchor=(1.05, 1), loc="upper left")
plt.tight_layout()
plt.show()
# Average salary pivot table
print("📊 Average Salary (Position × Department):")
pivot = df.pivot_table(values="salary", index="position", columns="department",
aggfunc="median").reindex(pos_order)
print(pivot.to_string(float_format="{:,.0f}".format))Phần 4: Correlation Matrix & Heatmap — "Bức tranh toàn cảnh"
OSEMN Explore Level 3: Multivariate — nhìn tất cả mối quan hệ cùng lúc.
Bước 4.1: Correlation Matrix
python
# Cell 13: Multivariate — Correlation Matrix
numeric_cols = ["salary", "age", "experience_years", "tenure_years",
"performance_score", "satisfaction_score", "training_hours"]
corr_matrix = df[numeric_cols].corr()
# Heatmap
plt.figure(figsize=(10, 8))
mask = np.triu(np.ones_like(corr_matrix, dtype=bool)) # Hide upper triangle
sns.heatmap(corr_matrix, annot=True, cmap="coolwarm", center=0,
fmt=".2f", linewidths=0.5, mask=mask,
vmin=-1, vmax=1, square=True)
plt.title("Correlation Matrix — Tất cả biến numeric", fontsize=14)
plt.tight_layout()
plt.show()
# Print strong correlations
print("📊 Strong Correlations (|r| > 0.4):")
for i in range(len(corr_matrix)):
for j in range(i + 1, len(corr_matrix)):
r = corr_matrix.iloc[i, j]
if abs(r) > 0.4:
print(f" {corr_matrix.index[i]:25s} ↔ {corr_matrix.columns[j]:25s}: r = {r:+.3f}")Bước 4.2: Pair Plot (Top Variables)
python
# Cell 14: Pair plot — top 4 biến liên quan nhất
top_vars = ["salary", "experience_years", "performance_score", "satisfaction_score"]
g = sns.pairplot(df[top_vars + ["attrition"]], hue="attrition",
palette={"No": "#2ecc71", "Yes": "#e74c3c"},
diag_kind="kde", plot_kws={"alpha": 0.5, "s": 20})
g.figure.suptitle("Pair Plot — 4 biến chính (colored by Attrition)", y=1.02, fontsize=14)
plt.tight_layout()
plt.show()Bước 4.3: Deep Dive — High-Risk Group
python
# Cell 15: Identify high-risk group (high perf + low satisfaction)
df["risk_group"] = "Normal"
df.loc[(df["performance_score"] >= 4.0) & (df["satisfaction_score"] <= 2.5), "risk_group"] = "High Risk"
df.loc[(df["performance_score"] >= 4.0) & (df["satisfaction_score"] > 2.5), "risk_group"] = "High Performer"
df.loc[(df["performance_score"] < 4.0) & (df["satisfaction_score"] <= 2.5), "risk_group"] = "Unhappy"
fig, axes = plt.subplots(1, 2, figsize=(14, 6))
# Scatter: Performance vs Satisfaction
colors = {"Normal": "#95a5a6", "High Risk": "#e74c3c",
"High Performer": "#3498db", "Unhappy": "#f39c12"}
for group, color in colors.items():
subset = df[df["risk_group"] == group]
axes[0].scatter(subset["performance_score"], subset["satisfaction_score"],
c=color, label=f"{group} ({len(subset)})", alpha=0.5, s=30)
axes[0].set_xlabel("Performance Score")
axes[0].set_ylabel("Satisfaction Score")
axes[0].set_title("Performance vs Satisfaction — Risk Groups", fontsize=13)
axes[0].legend(fontsize=9)
axes[0].axhline(2.5, color="red", linestyle=":", alpha=0.5)
axes[0].axvline(4.0, color="red", linestyle=":", alpha=0.5)
# Attrition rate by risk group
risk_attrition = df.groupby("risk_group")["attrition"].apply(
lambda x: (x == "Yes").mean() * 100
).reindex(["Normal", "High Performer", "Unhappy", "High Risk"])
bar_colors = [colors[g] for g in risk_attrition.index]
sns.barplot(x=risk_attrition.index, y=risk_attrition.values,
palette=bar_colors, ax=axes[1])
axes[1].set_title("Attrition Rate by Risk Group", fontsize=13)
axes[1].set_ylabel("Attrition Rate (%)")
for i, v in enumerate(risk_attrition.values):
axes[1].text(i, v + 0.5, f"{v:.1f}%", ha="center", fontsize=11, fontweight="bold")
plt.tight_layout()
plt.show()
# Summary
print("📊 Risk Group Analysis:")
risk_summary = df.groupby("risk_group").agg(
count=("employee_id", "count"),
avg_salary=("salary", "mean"),
avg_tenure=("tenure_years", "mean"),
attrition_rate=("attrition", lambda x: f"{(x == 'Yes').mean() * 100:.1f}%"),
).reindex(["Normal", "High Performer", "Unhappy", "High Risk"])
print(risk_summary.to_string())💡 EDA Framework: từ pattern → hypothesis → investigation
Chart cho thấy High Risk group (high perf + low satisfaction) có attrition rate cao → đây là pattern. Hypothesis: "nhân viên giỏi nhưng không hài lòng sẽ nghỉ việc". Investigation: cross-check với department, tenure, salary range → viết vào EDA report.
Phần 5: EDA Report — "5 Key Findings"
Bước cuối cùng và quan trọng nhất: tổng hợp tất cả charts thành 5 key findings + recommendations dạng actionable.
Bước 5.1: Summary Statistics Table
python
# Cell 16: EDA Summary Table
print("=" * 70)
print("📋 EDA REPORT — TECHVN HR EMPLOYEE ANALYTICS")
print("=" * 70)
print(f" Dataset: {df.shape[0]:,} employees × {df.shape[1]} variables")
print(f" Period: Current snapshot (Feb 2026)")
print(f" Analyst: [Your Name]")
print(f" Date: 2026-02-18")
print()
# Key metrics
total = len(df)
attrition_rate = (df["attrition"] == "Yes").mean() * 100
avg_salary = df["salary"].mean()
median_salary = df["salary"].median()
avg_exp = df["experience_years"].mean()
avg_perf = df["performance_score"].mean()
avg_sat = df["satisfaction_score"].mean()
high_risk = len(df[df["risk_group"] == "High Risk"])
print("📊 Key Metrics:")
print(f" Total employees: {total:,}")
print(f" Attrition rate: {attrition_rate:.1f}%")
print(f" Avg salary: {avg_salary:,.0f} VND")
print(f" Median salary: {median_salary:,.0f} VND")
print(f" Avg experience: {avg_exp:.1f} years")
print(f" Avg performance: {avg_perf:.2f} / 5.0")
print(f" Avg satisfaction: {avg_sat:.2f} / 5.0")
print(f" High-risk employees: {high_risk} ({high_risk/total*100:.1f}%)")Bước 5.2: 5 Key Findings Template
python
# Cell 17: 5 Key Findings — Template
# Hãy điền findings của bạn dựa trên charts đã vẽ!
findings = """
══════════════════════════════════════════════════════════════
📋 EDA REPORT: 5 KEY FINDINGS — TECHVN HR ANALYTICS
══════════════════════════════════════════════════════════════
🔍 FINDING 1: Salary Distribution & Inequality
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
- Observation: Salary right-skewed — mean > median
- Evidence: [Ghi skewness value, mean/median gap từ Cell 6]
- Impact: Report "lương trung bình" bằng mean sẽ misleading
- Recommendation: Dùng MEDIAN khi communicate salary metrics
cho toàn công ty. Chia salary band theo percentile (P25, P50, P75).
🔍 FINDING 2: Experience-Salary Relationship
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
- Observation: Strong positive correlation giữa exp và salary
- Evidence: [Ghi r value từ Cell 9, trend line equation]
- Impact: Mỗi năm kinh nghiệm tăng ~X triệu VND lương
- Recommendation: Xây salary band dựa trên experience bracket.
Review nhóm deviation > 2 std từ trend line (potential underpaid).
🔍 FINDING 3: Department Salary Gap
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
- Observation: [Phòng ban nào median cao nhất/thấp nhất? Gap bao nhiêu?]
- Evidence: [Ghi median & IQR từ Cell 10]
- Impact: [Phòng ban nào có spread lớn nhất? Outlier ở đâu?]
- Recommendation: Cross-check với market rate cho từng department.
Nếu Sales có outliers lớn → audit commission structure.
🔍 FINDING 4: Attrition Patterns
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
- Observation: Attrition rate overall = X%. Phòng ban Y cao nhất.
- Evidence: [Ghi rate từ Cell 11, satisfaction gap]
- Impact: Nhân viên nghỉ việc có satisfaction score thấp hơn ~Z điểm
- Recommendation: Focus retention program cho phòng ban có attrition
cao nhất. Exit interview đặc biệt cho nhóm satisfaction < 2.5.
🔍 FINDING 5: High-Performance Burnout Risk
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
- Observation: Nhóm High Risk (perf ≥ 4.0, satisfaction ≤ 2.5)
có attrition rate cao nhất
- Evidence: [Ghi count & rate từ Cell 15]
- Impact: Mất nhân viên giỏi = costly (recruitment + training)
- Recommendation: Immediate action: 1-on-1 meeting với nhóm High Risk.
Long-term: review workload, career path, compensation cho top performers.
══════════════════════════════════════════════════════════════
📎 NEXT STEPS:
1. Deep-dive attrition prediction model (Buổi 12+)
2. Dashboard visualization cho HR Director (Buổi 10-11)
3. Quarterly EDA refresh với data mới
══════════════════════════════════════════════════════════════
"""
print(findings)⚠️ Điền số liệu thực!
Template trên có placeholder [...] — bạn phải thay bằng số liệu thực từ các chart đã vẽ. EDA report có insight nhưng không có evidence = không thuyết phục.
🌟 Bonus Challenges
Bonus 1: Outlier Detection với IQR
python
# Cell 18: Bonus — Outlier Detection
def detect_outliers_iqr(series, name=""):
Q1 = series.quantile(0.25)
Q3 = series.quantile(0.75)
IQR = Q3 - Q1
lower = Q1 - 1.5 * IQR
upper = Q3 + 1.5 * IQR
outliers = series[(series < lower) | (series > upper)]
print(f"📊 {name}: Q1={Q1:,.0f} | Q3={Q3:,.0f} | IQR={IQR:,.0f}")
print(f" Bounds: [{lower:,.0f}, {upper:,.0f}]")
print(f" Outliers: {len(outliers)} ({len(outliers)/len(series)*100:.1f}%)")
if len(outliers) > 0:
print(f" Range: {outliers.min():,.0f} — {outliers.max():,.0f}")
return outliers
print("=" * 60)
print("📊 BONUS 1: OUTLIER DETECTION (IQR METHOD)")
print("=" * 60)
for col in ["salary", "experience_years", "training_hours", "tenure_years"]:
detect_outliers_iqr(df[col], col)
print()Bonus 2: Attrition Deep Dive — Pivot Table
python
# Cell 19: Bonus — Attrition pivot table
print("=" * 60)
print("📊 BONUS 2: ATTRITION DEEP DIVE")
print("=" * 60)
# Pivot: attrition rate by department × position
attrition_pivot = df.pivot_table(
values="attrition",
index="department",
columns="position",
aggfunc=lambda x: f"{(x == 'Yes').mean() * 100:.0f}%",
).reindex(columns=["Junior", "Mid", "Senior", "Lead", "Manager"])
print("\n📋 Attrition Rate (Department × Position):")
print(attrition_pivot.to_string())
# Highest risk cells
print("\n🚨 Highest Risk Combinations:")
for dept in df["department"].unique():
for pos in df["position"].unique():
subset = df[(df["department"] == dept) & (df["position"] == pos)]
if len(subset) > 5:
rate = (subset["attrition"] == "Yes").mean() * 100
if rate > 30:
print(f" ⚠️ {dept} × {pos}: {rate:.0f}% attrition ({len(subset)} employees)")Bonus 3: Automated EDA Summary Function
python
# Cell 20: Bonus — Reusable EDA function
def quick_eda(df, target_col=None, numeric_cols=None, cat_cols=None):
"""
Automated EDA summary function — reusable cho mọi dataset
"""
print("=" * 60)
print("📊 QUICK EDA SUMMARY")
print("=" * 60)
# Shape & types
print(f"\n📐 Shape: {df.shape[0]:,} rows × {df.shape[1]} cols")
print(f"📋 Numeric: {len(df.select_dtypes(include='number').columns)} cols")
print(f"📋 Categorical: {len(df.select_dtypes(include='object').columns)} cols")
print(f"🔁 Duplicates: {df.duplicated().sum()}")
# Missing
missing = df.isnull().sum()
if missing.sum() > 0:
print(f"\n⚠️ Missing Values:")
for col in missing[missing > 0].index:
print(f" {col}: {missing[col]} ({missing[col]/len(df)*100:.1f}%)")
else:
print("\n✅ No missing values!")
# Numeric summary
if numeric_cols is None:
numeric_cols = df.select_dtypes(include="number").columns.tolist()
if len(numeric_cols) > 0:
print(f"\n📈 Numeric Summary:")
for col in numeric_cols[:6]: # Top 6
skew = df[col].skew()
direction = "right↗" if skew > 0.5 else "left↙" if skew < -0.5 else "~normal"
print(f" {col:25s}: {df[col].mean():>12,.1f} (mean) | {df[col].median():>12,.1f} (median) | {direction}")
# Target analysis (if provided)
if target_col and target_col in df.columns:
print(f"\n🎯 Target: {target_col}")
print(df[target_col].value_counts().to_string())
return None
# Test
quick_eda(df, target_col="attrition")📋 Deliverable
Sau khi hoàn thành workshop, nộp:
| # | File | Mô tả |
|---|---|---|
| 1 | HoTen_Buoi09_EDA.ipynb | Jupyter Notebook hoàn chỉnh — chạy Restart & Run All thành công |
| 2 | EDA Report (trong notebook) | 5 Key Findings + Recommendations — Cell 17 đã điền số liệu thực |
💡 1 trang Summary
Ngoài notebook, hãy viết 1 trang summary (Markdown cell cuối) gồm:
- 📊 Dataset overview (1-2 câu)
- 🔍 Top 3 findings (mỗi finding 2-3 câu)
- 📎 Next steps (2-3 bullets)
Đây là dạng executive summary mà manager/HR Director sẽ đọc — ngắn gọn, actionable, không cần code.
📊 Rubric — Thang điểm
| Tiêu chí | Điểm | Mô tả |
|---|---|---|
| Data Overview (Phần 1) | 10 | info(), describe(), quality check đầy đủ |
| Univariate Analysis (Phần 2) | 20 | Histogram + KDE cho numeric, bar chart cho categorical, có interpretation |
| Bivariate Analysis (Phần 3) | 25 | Scatter plot + trend line, box plot, attrition analysis — charts có labels |
| Correlation & Heatmap (Phần 4) | 20 | Correlation matrix đúng, heatmap đẹp, pair plot, risk group analysis |
| EDA Report (Phần 5) | 20 | 5 key findings có evidence + recommendation, số liệu thực (không placeholder) |
| Notebook Clean | 5 | Markdown sections, Restart & Run All OK, no debug cells, interpretation |
| Bonus | +15 | Outlier detection (+5), Attrition pivot (+5), Quick EDA function (+5) |
| Tổng | 100 + 15 bonus |
⚠️ Lưu ý quan trọng
- Restart & Run All trước khi nộp — notebook phải chạy từ đầu đến cuối không lỗi
- Mỗi chart phải có Markdown interpretation — vẽ chart không comment = 0 điểm cho chart đó
- Finding phải có evidence (con số cụ thể) — không có số = finding vô nghĩa
- Code có comments giải thích logic quan trọng
- Không có cell test/debug còn sót lại