Skip to content

🛠 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ẽ:

  1. Load & overview dataset bằng df.info(), df.describe(), df.shape — nắm cấu trúc tổng quan
  2. Univariate analysis — vẽ histogram, KDE, bar chart cho từng biến quan trọng
  3. Bivariate analysis — scatter plot (salary vs experience), box plot (department vs salary)
  4. Correlation matrix — tính df.corr() + vẽ heatmap bằng Seaborn
  5. EDA Report — viết summary 5 key findings + recommendations dạng actionable

🧰 Yêu cầu

Yêu cầuChi 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)
PythonPython 3.8+
Thư việnpandas, numpy, matplotlib, seaborn (Colab đã có sẵn)
Thời gian60–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ộtKiểuMô tảGhi chú
employee_idstrMã nhân viên (unique)"E0001" → "E1500"
namestrTên nhân viênTên Việt
departmentstrPhòng banEngineering, Marketing, Sales, HR, Finance
positionstrVị tríJunior, Mid, Senior, Lead, Manager
ageintTuổi22–58
genderstrGiới tínhMale, Female
salaryfloatLương tháng (VND)8M–60M
experience_yearsintSố năm kinh nghiệm0–25
tenure_yearsfloatSố năm làm tại TechVN0.5–15
performance_scorefloatĐiểm đánh giá hiệu suất (1-5)Từ review cycle
satisfaction_scorefloatĐiểm hài lòng (1-5)Từ khảo sát nội bộ
training_hoursintSố giờ training/năm0–120
attritionstrĐã 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()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:

#FileMô tả
1HoTen_Buoi09_EDA.ipynbJupyter Notebook hoàn chỉnh — chạy Restart & Run All thành công
2EDA 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ểmMô tả
Data Overview (Phần 1)10info(), describe(), quality check đầy đủ
Univariate Analysis (Phần 2)20Histogram + KDE cho numeric, bar chart cho categorical, có interpretation
Bivariate Analysis (Phần 3)25Scatter plot + trend line, box plot, attrition analysis — charts có labels
Correlation & Heatmap (Phần 4)20Correlation matrix đúng, heatmap đẹp, pair plot, risk group analysis
EDA Report (Phần 5)205 key findings có evidence + recommendation, số liệu thực (không placeholder)
Notebook Clean5Markdown sections, Restart & Run All OK, no debug cells, interpretation
Bonus+15Outlier detection (+5), Attrition pivot (+5), Quick EDA function (+5)
Tổng100 + 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