Python Data Science Tutorial
Intermediate6 modules ยท 12 examples ยท NumPy โ Pandas โ Cleaning โ Visualisation โ ML โ End-to-End Project
๐ก Tip
Run in Jupyter Notebook for inline charts: jupyter notebook
NumPy โ Arrays & Math
NumPy (Numerical Python) is the foundation of the entire Python data science ecosystem. It provides the ndarray โ an N-dimensional array that is stored in contiguous memory and processed with vectorised C code. This makes NumPy operations 100โ1000ร faster than equivalent Python loops. Every major data science library (Pandas, scikit-learn, TensorFlow) stores data internally as NumPy arrays.
Creating Arrays and Basic Operations
Arrays are created from lists, with helper functions (zeros, ones, arange, linspace), or randomly. NumPy operations are elementwise by default โ no loops needed. Broadcasting allows arrays of different shapes to work together. The axis parameter controls whether operations run across rows (axis=1) or columns (axis=0).
import numpy as np
# Create arrays
a = np.array([1, 2, 3, 4, 5])
b = np.array([10, 20, 30, 40, 50])
print("a =", a)
print("b =", b)
print("a + b =", a + b) # elementwise add
print("a * b =", a * b) # elementwise multiply
print("a ** 2 =", a ** 2) # elementwise power
# Helper constructors
zeros = np.zeros((3, 3))
ones = np.ones((2, 4))
rng = np.arange(0, 20, 3) # 0 to 19, step 3
linsp = np.linspace(0, 1, 5) # 5 evenly spaced from 0 to 1
print("
np.arange(0,20,3) =", rng)
print("np.linspace(0,1,5) =", linsp)
# 2D array โ shape, reshape
matrix = np.arange(1, 13).reshape(3, 4)
print("
3ร4 matrix:
", matrix)
print("shape:", matrix.shape)
print("dtype:", matrix.dtype)
# Indexing & slicing
print("
matrix[0] =", matrix[0]) # first row
print("matrix[:,1] =", matrix[:, 1]) # second column
print("matrix[1:,2:]=
", matrix[1:, 2:]) # sub-matrix
# Boolean masking
data = np.array([5, 12, 3, 18, 7, 22, 1, 9])
mask = data > 8
print("
data =", data)
print("mask =", mask)
print("data[mask] =", data[mask])
# Statistics
print("
Mean: ", data.mean())
print("Std: ", data.std().round(2))
print("Min: ", data.min(), " Max:", data.max())
print("Sum: ", data.sum())
print("Cumsum:", data.cumsum())The key insight: never use Python for loops over NumPy arrays. Instead, use vectorised operations and broadcasting. A loop over 1 million elements takes ~1 second in Python; the same NumPy operation takes ~1 millisecond. This 1000ร speedup is why NumPy underpins all of data science.
Linear Algebra & Random Numbers
NumPy provides a complete linear algebra module (np.linalg) and a powerful random number generator. Linear algebra operations are the foundation of machine learning โ training a linear regression is literally solving a system of equations. The random module is essential for generating synthetic data, train/test splits, and initialising neural network weights.
import numpy as np
np.random.seed(42) # reproducibility
# โโ Linear Algebra โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
A = np.array([[2, 1], [5, 3]])
B = np.array([[1, 2], [4, 5]])
print("A @ B (matrix multiply):
", A @ B)
print("A.T (transpose):
", A.T)
print("det(A):", np.linalg.det(A).round(1))
# Solve Ax = b โ x = Aโปยนb
b_vec = np.array([8, 21])
x = np.linalg.solve(A, b_vec)
print("Solve Ax=b:", x) # [3. 2.]
# Eigenvalues / eigenvectors
eigenvalues, eigenvectors = np.linalg.eig(A)
print("Eigenvalues:", eigenvalues.round(3))
# โโ Random Numbers โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
# Uniform distribution [0, 1)
uniform = np.random.rand(5)
print("
Uniform:", uniform.round(3))
# Normal distribution (mean=0, std=1)
normal = np.random.randn(5)
print("Normal:", normal.round(3))
# Random integers
ints = np.random.randint(1, 101, size=10)
print("Random ints:", ints)
# Shuffle and choice
arr = np.arange(1, 11)
np.random.shuffle(arr)
print("Shuffled:", arr)
sample = np.random.choice(arr, size=4, replace=False)
print("Sample of 4:", sample)
# Normal with custom mean and std
heights = np.random.normal(loc=170, scale=10, size=1000)
print(f"
1000 simulated heights:")
print(f" mean = {heights.mean():.1f} cm")
print(f" std = {heights.std():.1f} cm")
print(f" min = {heights.min():.1f} cm")
print(f" max = {heights.max():.1f} cm")
# Correlation matrix
data = np.column_stack([
np.random.randn(100),
np.random.randn(100),
])
print("
Corr matrix:
", np.corrcoef(data.T).round(3))np.random.seed(42) makes your random results reproducible โ running the same code always produces the same numbers. Always set a seed in data science notebooks so others can reproduce your results. The number 42 is conventional but any integer works.
Pandas โ DataFrames
Pandas is the Swiss Army knife of data manipulation. Its DataFrame is a 2D labelled table โ think of a spreadsheet that you can manipulate with code. Pandas handles loading data from CSV, Excel, SQL databases, and JSON; cleaning and transforming it; grouping, aggregating, pivoting, merging, and time-series analysis. It is the single most important library in the Python data science stack.
Creating and Exploring DataFrames
A DataFrame is a 2D table with labelled columns and an index. Create one from a dictionary, a list of dicts, a CSV file, or a NumPy array. The head(), tail(), info(), describe(), and shape attributes give you an instant overview of any dataset. Series is the 1D column type โ a DataFrame is a dict of Series.
import pandas as pd
import numpy as np
# Create a DataFrame from a dictionary
data = {
'name': ['Alice', 'Bob', 'Carol', 'Dave', 'Eve', 'Frank'],
'age': [25, 32, 28, 45, 22, 38],
'city': ['NYC', 'LA', 'NYC', 'Chicago', 'LA', 'NYC'],
'salary': [75000, 92000, 68000, 110000, 55000, 98000],
'department': ['Eng', 'Mktg', 'Eng', 'Exec', 'Mktg', 'Eng'],
'years': [2, 7, 4, 15, 1, 9],
}
df = pd.DataFrame(data)
print("Shape:", df.shape)
print("
First 3 rows:")
print(df.head(3))
print("
Data types:")
print(df.dtypes)
print("
Basic stats:")
print(df[['age', 'salary', 'years']].describe().round(1))
# Selecting data
print("
Salary column (Series):")
print(df['salary'].values)
print("
Engineers only:")
eng = df[df['department'] == 'Eng']
print(eng[['name', 'salary', 'years']])
print("
Age > 30 AND salary > 90000:")
seniors = df[(df['age'] > 30) & (df['salary'] > 90000)]
print(seniors[['name', 'age', 'salary']])
# Add calculated column
df['salary_per_year'] = (df['salary'] / df['years']).round(0)
print("
Salary per year of experience:")
print(df[['name', 'salary', 'years', 'salary_per_year']].sort_values('salary_per_year', ascending=False))Always check df.shape, df.dtypes, and df.isnull().sum() first when you receive a new dataset. These three commands tell you: how many rows/columns, what type each column is (object=string means potential issues), and whether any values are missing (NaN). This EDA sanity-check takes 30 seconds and prevents hours of debugging later.
groupby, merge, and pivot_table
groupby is Pandas' most powerful tool โ it splits the DataFrame into groups, applies a function to each group, and combines the results. merge performs SQL-style joins between DataFrames. pivot_table creates Excel-style pivot tables. These three operations together handle 90% of real-world data analysis tasks.
import pandas as pd
import numpy as np
# Sales data
sales = pd.DataFrame({
'date': pd.date_range('2026-01-01', periods=12, freq='ME'),
'region': ['North','South','North','East','South','West'] * 2,
'product': ['A','A','B','B','A','B'] * 2,
'revenue': [12000,8500,15000,9200,11500,7800,13200,9800,14100,10500,12800,8200],
'units': [120, 85, 150, 92, 115, 78, 132, 98, 141, 105, 128, 82],
})
# groupby single column
print("Revenue by region:")
print(sales.groupby('region')['revenue'].sum().sort_values(ascending=False))
# groupby multiple columns with multiple aggregations
print("
Revenue & units by region and product:")
grouped = sales.groupby(['region','product']).agg(
total_revenue = ('revenue', 'sum'),
total_units = ('units', 'sum'),
avg_monthly = ('revenue', 'mean'),
months = ('revenue', 'count'),
).round(0)
print(grouped)
# pivot_table โ regions as rows, products as columns
print("
Pivot โ Revenue by region ร product:")
pivot = sales.pivot_table(
values = 'revenue',
index = 'region',
columns = 'product',
aggfunc = 'sum',
margins = True, # add totals row/col
)
print(pivot)
# merge โ join two DataFrames
targets = pd.DataFrame({
'region': ['North','South','East','West'],
'target': [50000, 40000, 35000, 30000],
})
analysis = (
sales.groupby('region')['revenue']
.sum()
.reset_index()
.rename(columns={'revenue': 'actual'})
.merge(targets, on='region')
)
analysis['vs_target_%'] = ((analysis['actual'] / analysis['target'] - 1) * 100).round(1)
analysis['status'] = analysis['vs_target_%'].apply(
lambda x: 'โ
Above' if x >= 0 else 'โ Below'
)
print("
Actual vs Target:")
print(analysis.to_string(index=False))agg() with a dictionary lets you apply different aggregation functions to different columns in one call. The named aggregation syntax โ total_revenue=("revenue","sum") โ gives clean column names. Always use .reset_index() after groupby when you need the result as a regular DataFrame you can merge or plot.
Data Cleaning
Data scientists spend 60-80% of their time cleaning data. Real-world datasets have missing values, duplicate rows, inconsistent formats, outliers, wrong data types, and messy strings. Pandas provides a comprehensive toolkit for handling all of these. Clean data is the difference between a model that works and one that produces nonsense.
Handling Missing Values
NaN (Not a Number) is Pandas' representation for missing data. isnull() detects NaN. fillna() fills missing values with a constant, mean, median, or forward/backward fill. dropna() removes rows or columns with NaN. The right strategy depends on why data is missing and how much is missing.
import pandas as pd
import numpy as np
# Dataset with realistic missing values
data = {
'name': ['Alice', 'Bob', None, 'Dave', 'Eve', 'Frank', 'Grace'],
'age': [25, None, 28, 45, 22, None, 31],
'salary': [75000, 92000, 68000, None, 55000, 98000, None],
'city': ['NYC', 'LA', 'NYC', 'Chicago', None, 'NYC', 'LA'],
'score': [88, 91, None, 79, 85, 94, None],
}
df = pd.DataFrame(data)
print("Original data:")
print(df.to_string())
print("
โโ Missing value analysis โโ")
missing = df.isnull().sum()
pct_missing = (df.isnull().mean() * 100).round(1)
summary = pd.DataFrame({'missing': missing, 'pct': pct_missing})
print(summary[summary['missing'] > 0])
# Strategy 1: Drop rows missing more than 2 values
df_clean = df.dropna(thresh=df.shape[1] - 1) # keep rows with at most 1 NaN
print(f"
After dropna(thresh): {len(df)} โ {len(df_clean)} rows")
# Strategy 2: Fill numeric with median
df2 = df.copy()
df2['age'] = df2['age'].fillna(df2['age'].median())
df2['salary'] = df2['salary'].fillna(df2['salary'].median())
df2['score'] = df2['score'].fillna(df2['score'].mean().round(1))
# Strategy 3: Fill categorical with mode or 'Unknown'
df2['city'] = df2['city'].fillna(df2['city'].mode()[0])
df2['name'] = df2['name'].fillna('Unknown')
print("
After filling missing values:")
print(df2.to_string())
print("
Remaining NaN:", df2.isnull().sum().sum())
# Strategy 4: Forward fill (great for time series)
ts = pd.DataFrame({
'date': pd.date_range('2026-01-01', periods=7),
'value': [100, None, None, 85, None, 92, None],
})
ts['ffill'] = ts['value'].ffill()
ts['bfill'] = ts['value'].bfill()
ts['interpolated'] = ts['value'].interpolate()
print("
Time series fill strategies:")
print(ts.to_string(index=False))Never blindly drop NaN rows โ you may lose important patterns. Investigate WHY data is missing: is it random (safe to fill), or does the absence itself carry information (e.g. a "no response" in a survey means something different from a sensor error)? Different columns in the same DataFrame often need different strategies.
Type Conversion, Strings & Duplicates
Real data is messy: numbers stored as strings, inconsistent capitalisation, extra whitespace, duplicated rows. Pandas string accessor (.str) gives you vectorised string methods. astype() converts column types. to_datetime() parses dates. drop_duplicates() removes repeated rows.
import pandas as pd
import numpy as np
# Messy dataset typical of imported CSV
messy = pd.DataFrame({
'id': ['001', '002', '003', '002', '004', '005'], # id as string, duplicate
'name': [' Alice ', 'BOB', 'carol smith', 'BOB', 'Dave-O'Brien', ' Frank '],
'age': ['25', '32', '28', '32', 'N/A', '38'], # age as string, N/A
'salary': ['$75,000', '$92,000', '$68,000', '$92,000', '$110,000', '$98,000'],
'hired_date': ['2024-01-15', '2023-06-20', '2024-03-01', '2023-06-20', '2022-09-15', '2023-11-30'],
'email': ['Alice@Example.COM', 'bob@test.com', 'carol@co.uk', 'bob@test.com', 'dave@work.io', 'frank@corp.net'],
})
print("Messy data:")
print(messy.to_string())
# โโ Fix string columns โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
messy['name'] = (messy['name']
.str.strip() # remove leading/trailing whitespace
.str.title() # Title Case
)
messy['email'] = messy['email'].str.lower().str.strip()
# โโ Convert salary: "$75,000" โ 75000 โโโโโโโโโโโโโโโโโโโโ
messy['salary_clean'] = (messy['salary']
.str.replace('$', '', regex=False)
.str.replace(',', '', regex=False)
.astype(int)
)
# โโ Convert age: "N/A" โ NaN โ int โโโโโโโโโโโโโโโโโโโโโโโ
messy['age_clean'] = pd.to_numeric(messy['age'], errors='coerce')
messy['age_clean'] = messy['age_clean'].fillna(messy['age_clean'].median()).astype(int)
# โโ Parse dates โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
messy['hired_date'] = pd.to_datetime(messy['hired_date'])
messy['hire_year'] = messy['hired_date'].dt.year
messy['tenure_days']= (pd.Timestamp.now() - messy['hired_date']).dt.days
# โโ Remove duplicates โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
print(f"
Before dedup: {len(messy)} rows")
clean = messy.drop_duplicates(subset=['id'], keep='first')
print(f"After dedup: {len(clean)} rows")
# โโ Final clean DataFrame โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
result = clean[['id','name','age_clean','salary_clean','hire_year','tenure_days','email']].copy()
result.columns = ['id','name','age','salary','hire_year','tenure_days','email']
print("
Clean data:")
print(result.to_string(index=False))Create a data cleaning function that takes a raw DataFrame and returns a clean one. This makes your pipeline reproducible โ when new data arrives, run the same function. Always keep the original data unchanged and create a new clean copy. Print the shape before and after each cleaning step so you can see what was removed.
Visualisation
Data visualisation transforms numbers into insight. Matplotlib provides the foundation with full control over every chart element. Seaborn builds on Matplotlib to create beautiful statistical charts with minimal code. In Jupyter notebooks, charts render inline. In scripts, use plt.savefig() to save or plt.show() to display. The golden rule: choose the chart type based on what question you are answering.
Matplotlib โ Core Charts
The pyplot interface (plt) is the standard way to use Matplotlib. fig, ax = plt.subplots() gives you full control with multiple subplots. Always add labels, titles, and legends โ a chart without context is useless. figsize=(width, height) is in inches. dpi=150 gives crisp output.
import matplotlib.pyplot as plt
import numpy as np
# โโ Sample data โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
months = ['Jan','Feb','Mar','Apr','May','Jun']
revenue = [12000, 15000, 11000, 18000, 22000, 19000]
costs = [8000, 9500, 8500, 11000, 14000, 12000]
profit = [r - c for r, c in zip(revenue, costs)]
# โโ Figure with 4 subplots โโโโโโโโโโโโโโโโโโโโโโโโโโโโ
fig, axes = plt.subplots(2, 2, figsize=(12, 8))
fig.suptitle('Business Dashboard Q1/Q2 2026',
fontsize=14, fontweight='bold', y=1.01)
# 1. Line chart โ revenue vs costs
ax1 = axes[0, 0]
ax1.plot(months, revenue, 'o-', color='#4DABF7', linewidth=2.5, label='Revenue', markersize=7)
ax1.plot(months, costs, 's-', color='#F59E0B', linewidth=2.5, label='Costs', markersize=7)
ax1.fill_between(months, costs, revenue, alpha=0.15, color='#00C896')
ax1.set_title('Revenue vs Costs')
ax1.set_ylabel('Amount ($)')
ax1.legend()
ax1.grid(True, alpha=0.3)
# 2. Bar chart โ monthly profit
ax2 = axes[0, 1]
colors = ['#00C896' if p > 0 else '#EF4444' for p in profit]
bars = ax2.bar(months, profit, color=colors, width=0.6, edgecolor='white', linewidth=0.5)
ax2.set_title('Monthly Profit')
ax2.set_ylabel('Profit ($)')
ax2.axhline(0, color='white', linewidth=0.5)
for bar, val in zip(bars, profit):
ax2.text(bar.get_x() + bar.get_width()/2, bar.get_height() + 100,
f'${val:,}', ha='center', va='bottom', fontsize=9)
# 3. Scatter with trend
np.random.seed(42)
x = np.random.randn(80) * 10 + 50
y = 2 * x + np.random.randn(80) * 8 + 5
ax3 = axes[1, 0]
ax3.scatter(x, y, alpha=0.6, c='#EC4899', s=40, edgecolors='none')
z = np.polyfit(x, y, 1)
p = np.poly1d(z)
x_line = np.linspace(x.min(), x.max(), 100)
ax3.plot(x_line, p(x_line), '--', color='#F59E0B', linewidth=2, label='Trend')
ax3.set_title('Scatter with Trend Line')
ax3.set_xlabel('X'); ax3.set_ylabel('Y')
ax3.legend()
# 4. Pie / donut chart
ax4 = axes[1, 1]
sizes = [35, 25, 20, 12, 8]
labels = ['Product A','Product B','Product C','Product D','Other']
clrs = ['#4DABF7','#00C896','#F59E0B','#EC4899','#8B5CF6']
wedges, texts, autotexts = ax4.pie(
sizes, labels=labels, colors=clrs,
autopct='%1.1f%%', startangle=90,
wedgeprops={'width': 0.5} # donut
)
ax4.set_title('Revenue by Product')
plt.tight_layout()
plt.savefig('dashboard.png', dpi=150, bbox_inches='tight')
plt.show()
print("Chart saved as dashboard.png")
# โโ Quick histogram โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
data = np.random.normal(170, 10, 1000)
plt.figure(figsize=(7, 4))
plt.hist(data, bins=30, color='#4DABF7', edgecolor='#0c1220', alpha=0.85)
plt.axvline(data.mean(), color='#F59E0B', lw=2, linestyle='--', label=f'Mean: {data.mean():.1f}')
plt.axvline(data.mean()-data.std(), color='#EC4899', lw=1.5, linestyle=':', label=f'-1ฯ: {data.mean()-data.std():.1f}')
plt.axvline(data.mean()+data.std(), color='#EC4899', lw=1.5, linestyle=':', label=f'+1ฯ: {data.mean()+data.std():.1f}')
plt.xlabel('Height (cm)'); plt.ylabel('Count'); plt.title('Height Distribution (n=1000)')
plt.legend(); plt.tight_layout(); plt.show()
print("Histogram rendered")Use plt.style.use("seaborn-v0_8-darkgrid") or plt.style.use("dark_background") for a professional look in one line. For consistent colours across a project, define a palette dict at the top and reference it everywhere. Always call plt.tight_layout() before saving to prevent labels being cut off.
Seaborn โ Statistical Visualisation
Seaborn makes statistical charts beautiful and concise. Its hue parameter automatically splits data by a categorical variable. pairplot creates a matrix of scatterplots โ perfect for understanding relationships between all pairs of variables. heatmap visualises correlation matrices. Seaborn integrates with Pandas DataFrames directly.
import seaborn as sns
import matplotlib.pyplot as plt
import pandas as pd
import numpy as np
sns.set_theme(style="darkgrid", palette="husl")
np.random.seed(42)
# โโ Dataset โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
n = 150
df = pd.DataFrame({
'age': np.random.randint(22, 55, n),
'salary': np.random.randint(40000, 150000, n),
'experience': np.random.randint(0, 25, n),
'score': np.random.uniform(60, 100, n).round(1),
'department': np.random.choice(['Eng','Mktg','Sales','HR'], n),
'gender': np.random.choice(['M','F'], n),
})
# Make salary correlate with experience
df['salary'] += df['experience'] * 2500
# โโ 1. Heatmap โ correlation matrix โโโโโโโโโโโโโโโโโโ
numeric = df[['age','salary','experience','score']]
corr = numeric.corr()
fig, axes = plt.subplots(1, 2, figsize=(12, 5))
sns.heatmap(corr, annot=True, fmt='.2f', cmap='coolwarm',
ax=axes[0], square=True, vmin=-1, vmax=1,
linewidths=0.5, cbar_kws={'shrink': 0.8})
axes[0].set_title('Correlation Matrix')
# โโ 2. Box plot โ salary by department โโโโโโโโโโโโโโโ
sns.boxplot(data=df, x='department', y='salary',
hue='department', palette='Set2',
ax=axes[1], showfliers=True)
axes[1].set_title('Salary Distribution by Department')
axes[1].set_ylabel('Salary ($)')
axes[1].tick_params(axis='x', rotation=15)
plt.tight_layout()
plt.savefig('seaborn_charts.png', dpi=150)
plt.show()
print("Chart 1 saved")
# โโ 3. Scatter with regression line โโโโโโโโโโโโโโโโโโ
g = sns.lmplot(data=df, x='experience', y='salary',
hue='department', height=5, aspect=1.4,
scatter_kws={'alpha': 0.5, 's': 30},
line_kws={'linewidth': 2})
g.set_axis_labels('Years of Experience', 'Annual Salary ($)')
g.figure.suptitle('Salary vs Experience by Department', y=1.02)
plt.tight_layout()
plt.savefig('regression_by_dept.png', dpi=150)
plt.show()
print("Chart 2 saved")
# โโ 4. Print summary stats โโโโโโโโโโโโโโโโโโโโโโโโโโโโ
print("
Salary stats by department:")
print(df.groupby('department')['salary'].describe().round(0).to_string())
# โโ Correlation values โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
print("
Key correlations:")
print(f" Experience โ Salary: {corr.loc['experience','salary']:.3f}")
print(f" Age โ Salary: {corr.loc['age','salary']:.3f}")
print(f" Score โ Salary: {corr.loc['score','salary']:.3f}")A correlation of 0.91 between experience and salary tells you experience is by far the biggest driver of pay โ more useful than any chart. Always compute the correlation matrix before visualising. Strong correlations (|r| > 0.7) are important features for machine learning models. Correlations close to 0 mean variables are independent.
Machine Learning with scikit-learn
scikit-learn is the gold-standard machine learning library โ consistent API, dozens of algorithms, preprocessing tools, cross-validation, and pipelines. The fit/predict/score pattern is the same for every algorithm. This modularity means you can swap a LinearRegression for a RandomForest with one line change. Always: split data first, scale features, use cross-validation, never touch the test set until final evaluation.
Linear Regression & Model Evaluation
Regression predicts a continuous value (salary, price, temperature). Linear regression fits a straight line through the data. Always split into train/test, scale features, and evaluate with multiple metrics (Rยฒ, RMSE, MAE). Cross-validation gives a more reliable estimate than a single split.
import numpy as np
import pandas as pd
from sklearn.model_selection import train_test_split, cross_val_score
from sklearn.linear_model import LinearRegression, Ridge
from sklearn.preprocessing import StandardScaler
from sklearn.metrics import r2_score, mean_squared_error, mean_absolute_error
from sklearn.pipeline import Pipeline
np.random.seed(42)
n = 500
# โโ Generate realistic salary data โโโโโโโโโโโโโโโโโโโ
experience = np.random.randint(0, 25, n)
education = np.random.choice([12, 14, 16, 18, 20], n) # years of school
age = experience + 22 + np.random.randint(0, 5, n)
performance= np.random.uniform(0.6, 1.0, n)
salary = (
30000
+ experience * 3200
+ (education - 12) * 2500
+ age * 400
+ performance * 20000
+ np.random.randn(n) * 8000
)
X = pd.DataFrame({
'experience': experience,
'education': education,
'age': age,
'performance': performance,
})
y = salary
# โโ Split โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, random_state=42
)
print(f"Train: {len(X_train)} Test: {len(X_test)}")
# โโ Pipeline: scale + fit โโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
model = Pipeline([
('scaler', StandardScaler()),
('reg', LinearRegression()),
])
model.fit(X_train, y_train)
# โโ Evaluate โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
y_pred = model.predict(X_test)
r2 = r2_score(y_test, y_pred)
rmse = np.sqrt(mean_squared_error(y_test, y_pred))
mae = mean_absolute_error(y_test, y_pred)
print(f"
Linear Regression Results:")
print(f" Rยฒ = {r2:.4f} (1.0 = perfect)")
print(f" RMSE = ${rmse:,.0f} (avg prediction error)")
print(f" MAE = ${mae:,.0f}")
# โโ Cross-validation โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
cv_scores = cross_val_score(model, X, y, cv=5, scoring='r2')
print(f"
5-Fold Cross-Validation Rยฒ:")
for i, s in enumerate(cv_scores, 1):
print(f" Fold {i}: {s:.4f}")
print(f" Mean: {cv_scores.mean():.4f} ยฑ {cv_scores.std():.4f}")
# โโ Feature coefficients โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
coef = model.named_steps['reg'].coef_
feature_importance = pd.DataFrame({
'feature': X.columns,
'coefficient': coef,
}).sort_values('coefficient', key=abs, ascending=False)
print("
Feature Coefficients (scaled):")
print(feature_importance.to_string(index=False))
# โโ Sample predictions โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
print("
Sample Predictions:")
sample = X_test.head(5).copy()
sample['actual'] = y_test.head(5).values.round(0)
sample['predicted'] = model.predict(X_test.head(5)).round(0)
sample['error'] = (sample['predicted'] - sample['actual']).round(0)
print(sample[['actual','predicted','error']].to_string())An Rยฒ of 0.89 means the model explains 89% of salary variance. The remaining 11% is either noise (natural random variation) or missing features. RMSE is in the same units as the target โ an RMSE of $8,847 means predictions are off by about $8,847 on average. Use MAE if outliers should be penalised equally; use RMSE if large errors are especially bad.
Classification & Random Forest
Classification predicts a category (spam/not-spam, fraud/legit, churn/stay). Random Forest builds many decision trees on random subsets of the data โ the ensemble is more accurate and robust than any single tree. Feature importance tells you which inputs matter most. The confusion matrix shows exactly which classes the model confuses.
import numpy as np
import pandas as pd
from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split, cross_val_score
from sklearn.ensemble import RandomForestClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import Pipeline
from sklearn.metrics import (classification_report,
confusion_matrix,
accuracy_score,
roc_auc_score)
np.random.seed(42)
# โโ Generate churn dataset โโโโโโโโโโโโโโโโโโโโโโโโโโโโ
X, y = make_classification(
n_samples=1000, n_features=8, n_informative=5,
n_redundant=1, random_state=42, class_sep=0.8,
)
feature_names = ['tenure','monthly_charges','num_products',
'support_calls','payment_delay',
'usage_gb','age','contract_length']
X_df = pd.DataFrame(X, columns=feature_names)
labels = ['Stayed', 'Churned']
X_train, X_test, y_train, y_test = train_test_split(
X_df, y, test_size=0.2, random_state=42
)
print(f"Train: {len(X_train)} Test: {len(X_test)}")
print(f"Churn rate: {y.mean():.1%}")
# โโ Random Forest โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
rf = RandomForestClassifier(
n_estimators=100, max_depth=8, random_state=42, n_jobs=-1
)
rf.fit(X_train, y_train)
y_pred = rf.predict(X_test)
y_prob = rf.predict_proba(X_test)[:, 1]
print(f"
Random Forest:")
print(f" Accuracy: {accuracy_score(y_test, y_pred):.4f}")
print(f" ROC-AUC: {roc_auc_score(y_test, y_prob):.4f}")
# โโ Classification report โโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
print("
Classification Report:")
print(classification_report(y_test, y_pred, target_names=labels))
# โโ Confusion matrix โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
cm = confusion_matrix(y_test, y_pred)
print("Confusion Matrix:")
print(f" Predicted:Stayed Predicted:Churned")
print(f"Actual:Stayed {cm[0,0]:4d} {cm[0,1]:4d}")
print(f"Actual:Churned {cm[1,0]:4d} {cm[1,1]:4d}")
# โโ Feature importance โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
importance = pd.DataFrame({
'feature': feature_names,
'importance': rf.feature_importances_,
}).sort_values('importance', ascending=False)
print("
Feature Importance:")
for _, row in importance.iterrows():
bar = 'โ' * int(row['importance'] * 100)
print(f" {row['feature']:20s} {bar} {row['importance']:.3f}")
# โโ Compare vs Logistic Regression โโโโโโโโโโโโโโโโโโโโ
lr = Pipeline([('s', StandardScaler()), ('m', LogisticRegression(random_state=42))])
lr.fit(X_train, y_train)
lr_pred = lr.predict(X_test)
print(f"
Logistic Regression Accuracy: {accuracy_score(y_test, lr_pred):.4f}")
print(f"Random Forest Accuracy: {accuracy_score(y_test, y_pred):.4f} โ better")ROC-AUC of 0.96 is excellent (1.0 = perfect, 0.5 = random). The confusion matrix shows 12 false positives (predicted churn but stayed) and 9 false negatives (predicted stay but churned). In a churn use case, false negatives (missed churners) are more costly โ consider adjusting the classification threshold with predict_proba to catch more churners at the cost of more false alarms.
End-to-End Project
Real data science is not just running a model โ it is a complete pipeline: load data, explore, clean, engineer features, split, train multiple models, evaluate, and interpret results. This module walks through a complete employee attrition analysis, putting together everything from modules 1โ5 in one cohesive workflow.
Employee Attrition Analysis โ Complete Pipeline
We build a complete pipeline to predict employee attrition (resignation) from HR data. Each step is documented and follows production best practices: reproducible splits, scaling inside a pipeline (no data leakage), multiple model comparison, and business-relevant evaluation.
import numpy as np
import pandas as pd
from sklearn.model_selection import train_test_split, cross_val_score, StratifiedKFold
from sklearn.preprocessing import StandardScaler, LabelEncoder
from sklearn.ensemble import RandomForestClassifier, GradientBoostingClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import Pipeline
from sklearn.metrics import classification_report, roc_auc_score
import warnings; warnings.filterwarnings('ignore')
np.random.seed(42)
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
# STEP 1: Generate synthetic HR dataset
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
n = 800
df = pd.DataFrame({
'age': np.random.randint(22, 58, n),
'tenure_years': np.random.randint(0, 20, n),
'salary': np.random.randint(35000, 150000, n),
'monthly_hours': np.random.normal(160, 25, n).clip(80, 260).round(0),
'num_projects': np.random.randint(1, 8, n),
'satisfaction': np.random.uniform(0.2, 1.0, n).round(2),
'last_evaluation': np.random.uniform(0.4, 1.0, n).round(2),
'department': np.random.choice(['Eng','Sales','HR','Mktg','Ops'], n),
'travel': np.random.choice(['None','Rarely','Frequent'], n,
p=[0.3, 0.5, 0.2]),
})
# Attrition driven by low satisfaction + high hours + low salary
attrition_prob = (
0.05
+ (1 - df['satisfaction']) * 0.4
+ (df['monthly_hours'] > 200).astype(float) * 0.2
+ (df['salary'] < 60000).astype(float) * 0.15
- df['tenure_years'] * 0.01
).clip(0, 0.85)
df['left'] = np.random.binomial(1, attrition_prob)
print("โ" * 55)
print("STEP 1: Dataset Overview")
print("โ" * 55)
print(f" Shape: {df.shape}")
print(f" Attrition rate:{df['left'].mean():.1%}")
print(f" Missing values:{df.isnull().sum().sum()}")
print(df[['age','salary','satisfaction','monthly_hours']].describe().round(1).to_string())
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
# STEP 2: Feature Engineering
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
print("
" + "โ" * 55)
print("STEP 2: Feature Engineering")
print("โ" * 55)
df['overworked'] = (df['monthly_hours'] > 200).astype(int)
df['low_satisfaction'] = (df['satisfaction'] < 0.5).astype(int)
df['salary_per_year'] = df['salary'] / (df['tenure_years'] + 1)
df['score_gap'] = df['last_evaluation'] - df['satisfaction']
# Encode categoricals
le_dept = LabelEncoder()
le_travel = LabelEncoder()
df['dept_encoded'] = le_dept.fit_transform(df['department'])
df['travel_encoded'] = le_travel.fit_transform(df['travel'])
features = [
'age','tenure_years','salary','monthly_hours','num_projects',
'satisfaction','last_evaluation','overworked','low_satisfaction',
'salary_per_year','score_gap','dept_encoded','travel_encoded',
]
print(f" Features used: {len(features)}")
print(f" Engineered: overworked, low_satisfaction, salary_per_year, score_gap")
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
# STEP 3: Train / Test Split
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
X = df[features]
y = df['left']
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, random_state=42, stratify=y
)
print("
" + "โ" * 55)
print("STEP 3: Train / Test Split")
print("โ" * 55)
print(f" Train: {len(X_train)} rows Test: {len(X_test)} rows")
print(f" Train attrition: {y_train.mean():.1%} Test: {y_test.mean():.1%} (stratified โ)")
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
# STEP 4: Train and Compare Models
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
print("
" + "โ" * 55)
print("STEP 4: Model Comparison (5-fold CV)")
print("โ" * 55)
models = {
'Logistic Regression': Pipeline([
('scaler', StandardScaler()),
('model', LogisticRegression(max_iter=500, random_state=42)),
]),
'Random Forest': RandomForestClassifier(
n_estimators=100, max_depth=8, random_state=42, n_jobs=-1
),
'Gradient Boosting': GradientBoostingClassifier(
n_estimators=100, learning_rate=0.1, max_depth=4, random_state=42
),
}
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
results = {}
for name, model in models.items():
scores = cross_val_score(model, X_train, y_train, cv=cv, scoring='roc_auc')
results[name] = scores
print(f" {name:25s} AUC = {scores.mean():.4f} ยฑ {scores.std():.4f}")
best_name = max(results, key=lambda k: results[k].mean())
print(f"
โ Best model: {best_name}")
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
# STEP 5: Final Evaluation on Test Set
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
print("
" + "โ" * 55)
print("STEP 5: Final Evaluation on Held-Out Test Set")
print("โ" * 55)
best_model = models[best_name]
best_model.fit(X_train, y_train)
y_pred = best_model.predict(X_test)
y_prob = best_model.predict_proba(X_test)[:, 1]
auc = roc_auc_score(y_test, y_prob)
print(f"
Final ROC-AUC: {auc:.4f}")
print("
Classification Report:")
print(classification_report(y_test, y_pred, target_names=['Stayed','Left']))
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
# STEP 6: Feature Importance
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
print("โ" * 55)
print("STEP 6: Top Risk Factors")
print("โ" * 55)
if hasattr(best_model, 'feature_importances_'):
imp = pd.DataFrame({
'feature': features,
'importance': best_model.feature_importances_,
}).sort_values('importance', ascending=False).head(8)
else:
imp = pd.DataFrame({
'feature': features,
'importance': abs(best_model.named_steps['model'].coef_[0]),
}).sort_values('importance', ascending=False).head(8)
for _, row in imp.iterrows():
bar = 'โ' * int(row['importance'] * 80)
print(f" {row['feature']:20s} {bar} {row['importance']:.3f}")stratify=y in train_test_split ensures both train and test sets have the same class ratio โ critical for imbalanced datasets. Feature importance reveals business insight: satisfaction is the #1 predictor of attrition, followed by overwork. This tells HR: improve satisfaction scores and reduce overtime to cut attrition โ more valuable than any chart.
You finished the Python DS Tutorial!
You can now load, clean, analyse, and visualise data with Pandas and Matplotlib, and build machine learning models with scikit-learn โ the complete data science pipeline.