A rigorous, project-driven course for final-year engineering students and aspiring ML/data-science professionals — 92 pages spanning mathematical foundations, 40+ techniques, from-scratch implementations, and 21 end-to-end real-dataset projects with an Indian business focus.
Start Learning Free
Create a free account to unlock the first 22 lessons — Prerequisites (math foundations) and Python for Data Science — yours to keep, no payment required.
After paying, email your name and UPI transaction ID to aimldstejas@gmail.com — you’ll get course access within 24 hours.
What You Will Learn
Every topic includes mathematical rigour, intuitive visuals, from-scratch implementations, and concise sklearn shortcuts — taught through real Indian business scenarios.
📐 Mathematical Foundations
Cost functions, gradient descent, matrix algebra, probability theory — all derived, not just stated.
🧠 40+ Techniques
Regression, Logistic, KNN, Naive Bayes, SVM, Decision Trees, Random Forest, Boosting, K-Means, PCA, Bayesian methods, and more.
🎬 Interactive Visuals
Gradient descent, decision boundaries, K-Means convergence — inline animated widgets you can play with.
🏭 Indian Datasets
Real estate in Ahmedabad, crop yields in Gujarat, loan defaults in Indian banks, attrition in Bengaluru.
⚙️ Scratch + sklearn
Each algorithm implemented in pure Python/NumPy first, then the concise sklearn version.
🚀 21 Full Projects
End-to-end real-dataset projects with verified results, business framing, and key takeaways.
Interactive: The Curriculum at a Glance
The whole 89-page journey from prerequisites to full projects — tap any block to preview that part of the course.
PyLearn
Machine Learning with Python
01 / 40
Machine Learning with Python
A rigorous, interview-ready course covering the complete ML curriculum — from mathematical foundations to production-grade implementations.
scikit-learnNumPy & PandasXGBoost & Ensembles65 in-depth modulesIndian business datasetsInteractive animations
What You Will Learn
This course is structured for final-year B.Tech students and professionals preparing for ML engineering or data science roles. Every topic includes mathematical rigour, intuitive animations, from-scratch implementations, and sklearn shortcuts.
📐 Mathematical Foundations
Cost functions, gradient descent, matrix algebra, probability theory — all derived, not just stated.
🧠 40+ Techniques
Regression, Logistic, KNN, Naive Bayes, SVM, Decision Trees, Random Forest, Boosting (AdaBoost/GBM/XGBoost), K-Means, PCA, Bayesian methods, and more.
Real estate in Ahmedabad, crop yields in Gujarat, loan defaults in Indian banks, employee attrition in Bengaluru.
⚙️ Scratch + sklearn
Each algorithm implemented in pure Python/NumPy first, then the concise sklearn version.
🧪 Practice Hub
Pyodide-powered browser editor, 14 graded exercises, a full mini-project, and a 28-question knowledge quiz.
Prerequisites
Python programming — functions, classes, list comprehensions, file I/O
NumPy and Pandas basics — array operations, DataFrame manipulation
Statistics, probability, and linear algebra — now covered in a dedicated 🧮 Prerequisites section right after this page, so you don't need them going in
🧮 New: A Prerequisites Section
Descriptive statistics, probability, and inferential statistics (hypothesis testing, confidence intervals, ANOVA, chi-square) now have their own dedicated pages, plus an EDA/charting primer — all before the ML curriculum begins. Every later page that leans on one of these ideas links straight back to the relevant page here. Linear Algebra and Calculus now have full dedicated pages too, covering vectors through SVD and derivatives through gradient descent.
How to Use This Course
Read through each concept section — animations play automatically; use Play/Pause/Reset controls.
Study the from-scratch Python implementation — trace through each line manually.
Run the sklearn version — observe the conciseness, connect it back to the math.
Open the Q&A cards at the bottom of each page — quick self-checks to confirm you understood the key ideas.
After working through the curriculum, attempt the Practice Hub — exercises + full ML mini-project + quiz.
🎓 Full Access — Learn in Any Order
Every section of the course is open — browse the sidebar and jump to whichever topic you need. The pages are arranged in a recommended learning order (prerequisites → foundations → techniques → projects), but you're free to explore non-linearly. Each Q&A card at the bottom of a page can be opened to check your understanding as you go.
Course Roadmap
All equations throughout the course are now rendered as proper typeset math (via KaTeX), not plain text approximations.
#
Section
Key Concepts
Difficulty
01
Getting Started
Course home
Foundational
02–07
🧮 Prerequisites
Descriptive stats, probability, inferential stats, linear algebra & calculus, EDA/charting
13 real-world case studies linked from their matching technique pages
Mixed
72–92
🚀 Full Projects
21 end-to-end real-dataset projects with verified results
Mixed
Interactive: The Curriculum at a Glance
Tap or click any block to jump straight to that section — the whole 88-page journey from prerequisites to full projects in one picture.
Prerequisites
Descriptive Statistics
The vocabulary every ML page in this course leans on: what kind of variable you have, and how to summarise it with a single number.
📊 Why this page exists
Every technique page in this course — Linear Regression, PCA, Evaluation & Validation, all of it — assumes you can read a mean, a variance, or a skewed distribution without stopping to look it up. This page is the one place that spells it out in full; later pages will just link back here with a one-line refresher.
Types of Variables
Before summarising data, you need to know what kind of data it is — the summary that makes sense for one type is meaningless for another.
Categorical (Qualitative)
Nominal — categories with no order. e.g. City: Surat, Ahmedabad, Mumbai
Ordinal — categories with a clear order. e.g. Rating: Low, Medium, High
Numerical (Quantitative)
Interval — ordered, equal spacing, no true zero. e.g. Temperature in °C
Ratio — ordered, equal spacing, and a true zero. e.g. Monthly revenue (₹), age, weight
This distinction resurfaces constantly: a decision tree can split on nominal city names directly, but linear regression needs them one-hot encoded first (see Feature Engineering); a chi-square test applies to nominal/ordinal data, while a t-test needs ratio/interval data.
Measures of Central Tendency
A single number that represents "the typical value" of a dataset. There are three, and which one you should trust depends on the shape of your data.
\text{Mean:}\quad \bar{x} = \frac{\sum x_i}{n}
Mean — sensitive to extreme values. A single ₹50 lakh outlier salary will drag the mean of a 20-person team upward, even if everyone else earns ₹6 lakh.
Median — the middle value once sorted; unaffected by extreme values. Preferred for skewed data (salaries, house prices, hospital bills).
Mode — the most frequent value; the only one of the three that works for nominal categorical data (e.g. "most common payment method").
Situation
Use
Roughly symmetric, no extreme outliers
Mean
Skewed distribution (income, prices, wait times)
Median
Nominal categories (most popular product)
Mode
Measures of Dispersion
Two datasets can share the exact same mean while looking completely different — dispersion measures how spread out the values are.
Variance squares each deviation from the mean before averaging — this makes large deviations count disproportionately more, but the result is in squared units (₹² doesn't mean anything intuitive). Standard deviation takes the square root back, returning to the original unit (₹) — which is why SD, not variance, is what gets reported and plotted in practice.
💡 Where this shows up later
Variance is the exact quantity PCA maximises when choosing components (see PCA), the "V" in the Bias-Variance tradeoff (see Evaluation & Validation), and the denominator in every standardisation/z-score step you'll do in Feature Engineering.
Skewness & Kurtosis
Skewness measures asymmetry — whether one tail of the distribution is longer than the other.
Positive (Right) Skew
Mean > Median > Mode. A long right tail — a few very high values pull the mean up. Classic example: household income in a Mehta Textiles employee dataset, where most staff earn ₹3–8 lakh but a handful of senior managers earn ₹40 lakh+.
Negative (Left) Skew
Mean < Median < Mode. A long left tail. Example: exam scores on an easy test, where most students cluster near full marks and a few very low scores drag the mean down.
\text{Pearson's Coefficient of Skewness} = \frac{3(\text{Mean}-\text{Median})}{\text{Standard Deviation}}
Positive skew is commonly fixed with a log transform before feeding a variable into a model that assumes normality (linear regression's residuals, for instance) — you'll do exactly this in the Wholesale Customer Segmentation and House Price Prediction full projects.
Kurtosis measures how heavy the tails are relative to a normal distribution — how likely extreme values are.
Type
Excess Kurtosis
Meaning
Leptokurtic
> 0
Heavier tails, more outliers than normal
Mesokurtic
≈ 0
Normal-distribution-like tails
Platykurtic
< 0
Thinner tails, fewer extreme values
Interactive: Watch Mean, Median & Mode Move as Skew Changes
Drag the slider from left-skewed through symmetric to right-skewed. Watch the three central-tendency measures reorder themselves exactly as the table above describes — mean is always pulled hardest toward the long tail, mode barely moves.
Left skewRight skew
The Four Types of Mean
"Average" usually means the arithmetic mean, but three other means matter in data science. Each answers a different question.
Mean
Use it for
Formula idea
Arithmetic
Everyday averaging of additive quantities
sum ÷ count
Geometric
Growth rates, ratios, compounding returns
n-th root of the product
Harmonic
Rates & ratios (e.g. average speed, F1-score)
count ÷ sum of reciprocals
Weighted
When some values count more than others
Σ(value×weight) ÷ Σweight
▶ Type the following code in a Jupyter notebook and run to get the output:
The arithmetic mean of the rates 60 km/h and 30 km/h over equal distances is not 45 — the correct average speed is the harmonic mean, 40 km/h. Using the wrong mean silently biases results.
More Measures of Dispersion
Beyond variance and standard deviation, three lighter measures are worth knowing. The coefficient of range is scale-free; the mean absolute deviation (MAD) averages the raw distances from the mean.
▶ Type the following code in a Jupyter notebook and run to get the output:
range: 11
coefficient of range: 0.44
mean absolute deviation: 2.625
sample variance: 11.9821
sample std dev: 3.4615
⚠ Population vs sample
Divide by n for a population variance, by n − 1 for a sample variance (Bessel's correction). In pandas/NumPy, .var() defaults differ: pandas uses n − 1, NumPy uses n — pass ddof=1 to be explicit.
Practice Questions
Q1A Bengaluru startup reports "average employee salary ₹18 lakh" but most employees are unhappy with pay. What statistical explanation fits, and what number would you ask for instead?+
Answer
This is the classic signature of a right-skewed distribution: a small number of very highly paid founders/executives can pull the mean up substantially even if the typical employee earns far less. If, say, 90 engineers earn ₹8–12 lakh and 3 founders earn ₹200 lakh+, the mean will look impressive while barely reflecting anyone's actual pay.
The median salary would be far more representative here, since it's unaffected by those extreme high values — it tells you what the middle employee actually earns, which is exactly why median (not mean) is the standard figure quoted in salary-survey journalism.
Q2Why does variance use squared deviations instead of just averaging (xᵢ − x̄) directly?+
Answer
If you averaged the raw deviations (xᵢ − x̄) directly, the positive and negative deviations would always cancel out to exactly zero by definition of the mean — you'd get zero regardless of how spread out the data actually is, making it useless as a dispersion measure.
Squaring each deviation before averaging makes every term positive, so they can't cancel, and it also has the side effect of penalising large deviations more than small ones (a point twice as far from the mean contributes four times as much to the variance) — which is a deliberate design choice, not an accident, since it makes variance sensitive to genuine outliers.
Q3A dataset of hospital bill amounts in Ahmedabad has mean = ₹64,000 and median = ₹41,000. What does this gap tell you, and what would you do before feeding "bill amount" into a linear regression?+
Answer
Mean substantially greater than median is the signature of positive (right) skew — most patients have moderate bills, but a smaller number of very expensive procedures (ICU stays, major surgery) push the mean well above the typical case. This is extremely common for cost/price/income variables in the real world.
Before using this as an input (or target) in linear regression, a log transform is the standard fix — it compresses the long right tail, making the distribution closer to normal and the relationship with other variables closer to linear, which is exactly what OLS regression's assumptions require. You'll see this exact move applied to sale prices in the House Price Prediction full project.
Python for Data Science
Python Basics
The working subset of Python every data scientist leans on daily — variables, types, operators, strings, collections, functions, loops, and error handling — each shown as runnable code with its real output.
Python is a high-level, interpreted language created by Guido van Rossum in 1991. Its readable, low-ceremony syntax lets you express an idea in far fewer lines than C++ or Java, which is exactly why it became the default language of data science: the libraries you'll use throughout this course — NumPy, pandas, Matplotlib, scikit-learn — are all built for Python.
🧘 The Zen of Python
Typing import this in any Python interpreter prints Tim Peters' guiding aphorisms. The ones that matter most as you write data-science code: Beautiful is better than ugly. Simple is better than complex. Readability counts. Explicit is better than implicit. There should be one — and preferably only one — obvious way to do it.
Variables & Dynamic Typing
You never declare a type in Python. A variable springs into existence the moment you assign to it, and its type is inferred at runtime — you can even reassign a name to a different type later.
▶ Type the following code in a Jupyter notebook and run to get the output:
# A variable is created on assignment; its type is inferred
x = 10
print(x, type(x).__name__)
x = "Surat"# same name, now a string
print(x, type(x).__name__)
x = 3.14# now a float
print(x, type(x).__name__)
Output
10 int
Surat str
3.14 float
Built-in Data Types
Python's core types fall into a small, memorable hierarchy. Almost everything you handle in data science is one of these — or a NumPy/pandas structure built on top of them.
Operators
Arithmetic
▶ Type the following code in a Jupyter notebook and run to get the output:
a, b = 17, 5
print(f"a + b = {a + b}") # addition
print(f"a - b = {a - b}") # subtraction
print(f"a * b = {a * b}") # multiplication
print(f"a / b = {a / b}") # true division (float)
print(f"a // b = {a // b}") # floor division
print(f"a % b = {a % b}") # modulus (remainder)
print(f"a ** b = {a ** b}") # exponent
Output
a + b = 22
a - b = 12
a * b = 85
a / b = 3.4
a // b = 3
a % b = 2
a ** b = 1419857
Comparison & Logical
▶ Type the following code in a Jupyter notebook and run to get the output:
a, b = 10, 20
print(a == b, a != b, a > b, a < b)
print((5 > 3) and (7 > 5))
print((5 > 3) or (7 < 5))
print(not (5 > 3))
Output
False True False True
True
True
False
Bitwise
Bitwise operators work on the binary representation of integers — handy for flags, masks, and low-level tricks. A left shift by 1 doubles a number; a right shift halves it.
▶ Type the following code in a Jupyter notebook and run to get the output:
When several operators appear together, Python follows a fixed order: Parentheses → Exponent → Multiply/Divide → Add/Subtract, then bitwise shifts, then comparisons, then logical. Parenthesise when in doubt.
▶ Type the following code in a Jupyter notebook and run to get the output:
A quick mixed example touching each operator family — predict each line, then check against the output.
▶ Type the following code in a Jupyter notebook and run to get the output:
print(5 == 5and6 == 7) # both must be True?
print(15 % 4) # remainder of 15 ÷ 4
a, b = 10, 20
print(a != b) # is a different from b?
print(7in [1, 2, 3, 4, 5, 6, 7]) # membership test
x = 5
x *= 3# compound assignment
print(x)
Output
False
3
True
True
15
Strings
Strings are immutable sequences of characters with a rich method set. Slicing uses [start:end]; negative indices count from the end.
▶ Type the following code in a Jupyter notebook and run to get the output:
s = "Hello, World!"
print(s.upper()) # HELLO, WORLD!
print(s.lower()) # hello, world!
print(s.replace("H", "J")) # swap a character
print(s.split(",")) # split into a list
print(len(s)) # number of characters
print(s[0:5], s[-1]) # slice + last character
A common string-cleaning example: take a messy country name, strip surrounding spaces, keep the first three letters, and capitalise them.
▶ Type the following code in a Jupyter notebook and run to get the output:
country = " bangladesh "
result = country.strip()[:3].upper() # strip → slice → upper
print(result)
Output
BAN
Lists & List Comprehensions
Lists are ordered, mutable collections. A list comprehension builds a new list in a single readable line — the most common idiom you'll write in data prep.
▶ Type the following code in a Jupyter notebook and run to get the output:
nums = [1, 2, 3, 4, 5]
squares = [x ** 2for x in nums] # comprehension
print(squares)
nums.append(6) # add to end
print(nums)
nums.pop() # remove last
print(nums)
evens = [x for x in range(10) if x % 2 == 0] # with a filter
print(evens)
Functions package reusable logic. Parameters can have defaults; *args captures extra positional arguments into a tuple, and **kwargs captures extra keyword arguments into a dict.
▶ Type the following code in a Jupyter notebook and run to get the output:
# Default argumentdef describe_pet(pet_name, animal_type="dog"):
returnf"{pet_name} is a {animal_type}"
print(describe_pet("Bruno"))
print(describe_pet("Whiskers", "cat"))
# *args — any number of positional valuesdef make_pizza(*toppings):
return", ".join(toppings)
print(make_pizza("cheese", "olives", "capsicum"))
# **kwargs — any number of keyword valuesdef build_profile(first, last, **info):
profile = {"first": first, "last": last}
profile.update(info)
return profile
print(build_profile("Riya", "Shah", city="Mumbai", role="Engineer"))
Output
Bruno is a dog
Whiskers is a cat
cheese, olives, capsicum
{'first': 'Riya', 'last': 'Shah', 'city': 'Mumbai', 'role': 'Engineer'}
Loops
A for loop iterates over a sequence; a while loop repeats while a condition holds. Python has no built-in do-while, but a while True with a break at the bottom simulates one (the body runs at least once).
▶ Type the following code in a Jupyter notebook and run to get the output:
# for loop over a listfor color in ["red", "green", "blue"]:
print(color)
# while loop — countdown
i = 3while i > 0:
print(f"countdown {i}")
i -= 1# do-while simulation — runs at least once
n = 0whileTrue:
print(f"runs at least once: {n}")
n += 1if n >= 2:
break
Output
red
green
blue
countdown 3
countdown 2
countdown 1
runs at least once: 0
runs at least once: 1
Exception Handling
Wrap risky code in try/except so a bad input doesn't crash the whole program. finally always runs — ideal for cleanup like closing a file.
▶ Type the following code in a Jupyter notebook and run to get the output:
try:
r = 10 / 0except ZeroDivisionError:
print("Cannot divide by zero")
try:
lst = [1, 2, 3]
print(lst[5])
except (IndexError, ZeroDivisionError) as e:
print(f"Caught: {type(e).__name__}")
finally:
print("cleanup always runs")
Output
Cannot divide by zero
Caught: IndexError
cleanup always runs
Putting It Together — Two Mini-Scripts
Generate a Secure Password
Build a random 14-character password from letters, digits, and symbols. The seed is fixed here only so the output is reproducible.
▶ Type the following code in a Jupyter notebook and run to get the output:
import random, string
random.seed(42) # fixed only for a repeatable demo
chars = string.ascii_letters + string.digits + "!@#$%^&*"
pwd = "".join(random.choice(chars) for _ in range(14))
print(pwd)
Output
odJFCrn*l2edlB
Calculate Age from Date of Birth
Compute a person's age in whole years, correctly handling whether this year's birthday has already passed.
▶ Type the following code in a Jupyter notebook and run to get the output:
from datetime import date
dob = date(1998, 5, 17)
today = date(2025, 1, 20)
# subtract 1 if this year's birthday hasn't happened yet
years = today.year - dob.year - ((today.month, today.day) < (dob.month, dob.day))
print(f"Age: {years} years")
Output
Age: 26 years
✅ What You Can Now Do
You can declare variables, use every operator family, manipulate strings and the four core collections, write functions with flexible arguments, control flow with loops, and handle errors gracefully. Next up: Python's built-in data structures in depth, plus object-oriented programming.
Python for Data Science
Interactive Visualization with Plotly
Matplotlib and Seaborn produce static images. Plotly produces interactive charts — hover for exact values, zoom, pan, and rotate 3-D views right in the browser. Every chart on this page is live: try hovering and dragging.
💡 Install & import
Plotly installs with pip install plotly. The examples use Plotly Express (plotly.express as px) — its high-level API builds a full interactive figure in one line. The charts you see below are the same figures rendered live in your browser.
All examples use one small sales table:
▶ Type the following code in a Jupyter notebook and run to get the output:
Plotly's signature trick: a true 3-D plot you can rotate by dragging. Impossible with static Matplotlib. Click and drag the chart below to spin it.
▶ Type the following code in a Jupyter notebook and run to get the output:
fig = px.scatter_3d(cities, x='Sales', y='Profit', z='Units',
color='City', title='Sales / Profit / Units in 3-D')
fig.show()
⚠ Dashboards with Dash — beyond this course
Plotly's companion library Dash turns these charts into full interactive web-app dashboards with dropdowns and sliders. Because Dash runs its own web server, it needs a Python backend and can't live inside a single static HTML page — so we note it here but don't build one. It's a natural next step once you deploy models.
✅ What You Can Now Do
You can build interactive line, bar, bubble, and 3-D charts with Plotly Express — the go-to when your audience needs to explore the data themselves rather than read a fixed image. Next: applying all three libraries in a full univariate EDA.
Data Engineering
Data Acquisition — APIs & Web Scraping
Before you can clean, engineer, or model data, you have to get it. This is where real data-science projects actually begin.
Where Data Science Really Begins
Every earlier page in this course quietly assumed the data was already sitting in a CSV, ready to load. In the real world it almost never is. Data has to be acquired — pulled from a live web service, scraped off a public page, or read out of a company database — before a single line of cleaning or modelling can happen. The full arc of a real project looks like this:
💡 Why a Data Scientist Must Know Data Engineering
Data scientists today are not only expected to have knowledge up to "machine-learning model building" — they are expected to have at least basic knowledge of data engineering. The reason is simple: if you cannot acquire data, there is no question of data cleaning, preprocessing, feature engineering, or model building. In real industry, most of the time the data will have to be mined by scraping or obtained using APIs before any of the modelling you've learned so far can even begin. That is why this section is part of the course.
Beyond this, a new role — the full-stack data scientist — is emerging: someone who can also do MLOps, i.e. deploy the machine-learning model, analyse drift in the model, and periodically and automatically re-train it (the machine-learning engineer's role). An interested learner who wants to become a true end-to-end full-stack data scientist can look to our separate MLOps course for that deployment-and-monitoring half of the diagram above.
An API is a structured "front door" a service opens so that programs — not humans clicking a browser — can request data and get back a clean, machine-readable response (almost always JSON). When a provider offers an API, it is nearly always the best acquisition method: the data is structured, reliable, and you're using the service the way it was designed to be used. The trade-off is that APIs are usually rate-limited and often require an authentication key.
The Python requests library is the standard tool. The pattern is always the same three steps: build the URL, send a GET request, parse the JSON. Below we fetch live package metadata from the public PyPI JSON API (no key required) — a real, working endpoint:
import requests
defget_package_info(pkg):
url = f"https://pypi.org/pypi/{pkg}/json"# 1. build the URL
resp = requests.get(url, timeout=10) # 2. send GET requestif resp.status_code == 200: # 200 = success
data = resp.json() # 3. parse JSON → dict
info = data['info']
return {
'name': info['name'],
'version': info['version'],
'summary': info['summary'],
'releases': len(data['releases']),
}
returnf"Error: status {resp.status_code}"for pkg in ['requests', 'scikit-learn', 'pandas']:
print(get_package_info(pkg))
Output (live at time of writing)
{'name': 'requests', 'version': '2.34.2', 'summary': 'Python HTTP for Humans.', 'releases': 163}
{'name': 'scikit-learn', 'version': '1.9.0', 'summary': 'A set of python modules for machine learning and data mining', 'releases': 75}
{'name': 'pandas', 'version': '3.0.3', 'summary': 'Powerful data structures for data analysis, time series, and statistics', 'releases': 116}
Those version numbers and release counts are genuinely fetched at runtime — run the code tomorrow and they may differ, which is exactly the point of a live API. For services that need a key (stock prices, weather, maps), the only change is appending &apikey=YOUR_KEY to the URL; many providers (e.g. Alpha Vantage for stock data) issue a free key to anyone with an email address.
🔑 The status_code Habit
Always check resp.status_code == 200 before trusting resp.json(). A 401 means bad/missing key, 429 means you've hit the rate limit, 404 means the URL is wrong. Real acquisition code is mostly error-handling — the happy path is the easy part.
Method 2 — Web Scraping
When a website shows data publicly on a page but offers no API, the fallback is web scraping: fetch the raw HTML and extract the fields you want. requests downloads the page; BeautifulSoup parses the HTML into a searchable tree. The classic safe practice site books.toscrape.com exists specifically for this:
import requests
from bs4 import BeautifulSoup
resp = requests.get('http://books.toscrape.com/', timeout=10)
soup = BeautifulSoup(resp.text, 'html.parser')
# Each book is an <article class="product_pod"> on the pagefor book in soup.find_all('article', class_='product_pod')[:3]:
title = book.h3.a['title'] # attribute of a nested tag
price = book.find('p', class_='price_color').text # text inside a tag
print(f"{title[:40]:40s} {price}")
Output
A Light in the Attic £51.77
Tipping the Velvet £53.74
Soumission £50.10
The two workhorse operations are find_all(tag, class_=...) to get a list of matching elements, and then .text (inner text) or ['attr'] (attribute value) to pull the actual data out of each. When a page loads its content dynamically with JavaScript, plain requests won't see it (it only gets the initial HTML) — you then need Selenium to drive a real browser that runs the JavaScript first, and hand its rendered page to BeautifulSoup.
✓ Prefer an API when
The provider offers one — it's structured and stable
You need reliability and clear usage terms
The data updates and you want clean repeat access
✗ Fall back to scraping only when
No API exists but the data is publicly visible
You've checked it's legally & ethically permitted
You can throttle requests to not overload the site
The Ethics & Law of Data Acquisition
Just because data is technically reachable does not mean it is yours to take. Responsible acquisition rests on three duties:
Principle
What it means in practice
Privacy & consent
Collect personal data only with informed consent; anonymise where possible.
Transparency
Be clear about what you collect, how it's used, and with whom it's shared.
Legal compliance
Obey the applicable laws — GDPR (Europe), CCPA (California), India's DPDP Act, and others.
Concrete best practices for scraping specifically: respect robots.txt (the file where a site declares which paths crawlers may not touch), apply rate limiting so you don't overload the server, and practise data minimisation — collect only what you actually need. Python's standard library can check robots.txt for you before you fetch anything:
from urllib.robotparser import RobotFileParser
defmay_i_scrape(base_url, path):
rp = RobotFileParser()
rp.set_url(base_url + '/robots.txt')
rp.read()
# '*' = rules for all bots; returns True only if the path is allowedreturn rp.can_fetch('*', base_url + path)
if may_i_scrape('http://books.toscrape.com', '/'):
print("Allowed — proceed politely (with delays between requests).")
else:
print("Disallowed — do not scrape this path.")
⚠ Real Cases That Set the Boundaries
LinkedIn v. hiQ Labs (2017): courts allowed scraping of public profile data over LinkedIn's objection — public data scraping isn't automatically illegal, but it's contested and fact-specific. Cambridge Analytica (2018): improperly harvested tens of millions of Facebook users' data without consent; Facebook was fined US$5 billion by the FTC — consent is not optional. Clearview AI (since 2020): scraped billions of web images to train facial recognition and has been fined and banned in multiple countries. The lesson across all three: technically possible and legally/ethically permitted are very different questions — always answer the second one first.
❓ Conceptual Q&A
Q1Why prefer an API over web scraping when both are available?
An API returns structured, stable data in a documented format the provider intends you to use, with clear terms and reliability. Scraping depends on the page's HTML layout — which can change without notice, silently breaking your code — and can raise legal/ethical issues around consent and server load. Scraping is the fallback for when no API exists, not the first choice.
Q2Plain requests returns an empty result for a page you can clearly see in your browser. Why, and what's the fix?
The page most likely renders its content dynamically with JavaScript after the initial HTML loads. requests only downloads that initial HTML and does not run any JavaScript, so the data isn't there yet. The fix is Selenium, which drives a real browser that executes the JavaScript; you then pass the fully-rendered driver.page_source to BeautifulSoup.
Q3What is robots.txt and are you legally bound by it?
robots.txt is a file at a site's root where it declares which paths automated crawlers should not access. Honouring it is a core ethical best practice and often referenced in terms of service, though its precise legal force varies by jurisdiction and case (as the LinkedIn v. hiQ dispute showed). Regardless of the letter of the law, ignoring it — plus hammering a server with unthrottled requests — is exactly the behaviour that gets scrapers blocked and sued.
Getting Started
The Machine Learning Workflow
Every production ML system follows a structured pipeline. Understanding each stage — and common failure modes — separates a practitioner from someone just running notebook cells.
Mapping This Pipeline to CRISP-DM — The Industry-Standard Cycle
All six phases read and write the shared Data repository (dashed spokes); the amber dashed arrow is the "reframe" loop the callout below describes.
The 5-stage pipeline above is this course's teaching structure, but in industry you'll frequently hear the process described using CRISP-DM (Cross-Industry Standard Process for Data Mining) — a vendor-neutral process model that predates most modern ML tooling but still anchors how most data science teams structure their work. Knowing the mapping helps you talk about your process with anyone who learned it that way.
1. Business Understanding
≈ Stage 1: Problem Framing above. What decision is being automated, and what does success look like?
2. Data Understanding
≈ early Stage 2/3: what data exists, its quality, its coverage of the population you'll actually deploy on.
3. Data Preparation
≈ Stage 3: EDA & Preprocessing above, and the entire Data Preprocessing & Feature Engineering page.
4. Modeling
≈ Stage 4: Model Training & Tuning above — the part of the process most tutorials focus on exclusively.
5. Evaluation
≈ the entire Model Evaluation & Cross-Validation page — including whether results are good enough to justify deployment at all.
6. Deployment
≈ Stage 5: Deployment & Monitoring above.
💡 The One Detail Most Summaries Leave Out
CRISP-DM is drawn as a cycle, not a straight line, for a reason: Business Understanding and Data Understanding constantly loop back into each other (the data you find often reshapes the business question you can even ask), and Evaluation routinely sends you all the way back to Business Understanding — not because the model failed technically, but because it revealed the original framing was wrong. Treating any of these stages as strictly one-directional is the single most common reason real ML projects run over schedule: teams budget for a straight line and get a cycle instead.
One consequence worth internalising: reaching Evaluation and discovering the model doesn't clear the bar is not a failure of the process — it is the process working. A data science team that iterates through this cycle two or three times before deployment, refining the business question each time, will typically ship a better and more trusted model than one that treats the first pass through Modeling as final.
Stage 1: Problem Framing
Before writing a single line of code, convert a vague business goal into a precise ML problem statement. This is the most underrated stage — poor framing wastes months of work.
⚠️ Common Framing Mistake
Business stakeholder: "We want AI to improve sales." This is not an ML problem statement. You must ask: What decisions do we want to automate? What data is available? What metric defines success? What are the consequences of false positives vs. false negatives?
Framing Checklist
Objective: Define y precisely. (Churn in 30 days? Revenue next quarter? Fraud probability?)
The Unifying Idea Behind "Training": Empirical Risk Minimization
Every algorithm across this entire course — Linear Regression, Logistic Regression, Decision Trees, SVM, Neural Networks — is secretly doing the exact same thing under the hood, just with different building blocks. This unifying framework is called Empirical Risk Minimization (ERM), and once you see it, every "new" algorithm you meet becomes a variation on one formula rather than a completely separate thing to memorise.
Three choices fully determine any supervised ML algorithm you've studied in this course:
Choice
What it controls
Examples from this course
Hypothesis space f_θ
What shape of function is even allowed
Linear (Linear/Logistic Regression), tree splits (Decision Trees), kernel-mapped hyperplane (SVM)
Loss function L
How "wrong" a single prediction is penalised
Squared error (OLS), cross-entropy (Logistic Regression), hinge loss (SVM), absolute error (LAD Regression)
Regularisation R(θ)
How harshly model complexity itself is penalised, independent of fit quality
L2 penalty (Ridge), L1 penalty (Lasso), tree depth limits, dropout
Seen this way, Ridge Regression isn't a separate algorithm from Linear Regression — it's the same ERM problem (linear hypothesis space, squared-error loss) with an L2 regularisation term added. Logistic Regression differs from Linear Regression in exactly one place: the loss function swaps from squared error to cross-entropy. This is precisely why so many algorithms across this course shared the same gradient formula ∇J = Xᵀ(ŷ−y) — they're all ERM instances differing only in how ŷ is computed, not in the underlying optimisation machinery.
💡 Why "Empirical" Risk, Specifically
The word "empirical" is doing real work here: we can never directly minimise the true risk (expected loss over the entire real-world data-generating distribution) because we only ever have a finite sample. ERM is the practical stand-in — minimise average loss over the training sample we actually have, and hope (with theoretical guarantees under the right conditions) that it generalises to the true distribution. This gap between empirical risk and true risk is exactly what overfitting is: a model that minimises training loss beautifully while the true risk stays high.
Monitor with Population Stability Index (PSI); set alerts
Concept drift
The target relationship changes (fraud patterns evolve)
Retrain periodically; use online learning
Training-serving skew
Preprocessing in training differs from inference code
Serialise full sklearn Pipeline with joblib
Latency constraints
Model too slow for real-time API (<100ms for fraud detection)
Model pruning, ONNX conversion, feature caching
Practice Questions
Q1Why must you fit the StandardScaler only on training data, not the full dataset?+
Answer
StandardScaler normalises features: x' = (x − μ) / σ. If you compute μ and σ on the full dataset (including test rows), the test statistics "leak" into the training process.
Concretely: suppose your test set contains extreme outlier property prices in Ahmedabad. These inflate the dataset mean μ and std σ. When you scale the training set using these contaminated statistics, you're adjusting training data based on knowledge of test distribution — which wouldn't be available in production. This produces over-optimistic validation results.
Correct procedure:scaler.fit(X_train) → scaler.transform(X_train) → scaler.transform(X_test). The scaler learns μ and σ exclusively from training data and applies the same statistics to transform the test set. Using sklearn Pipelines enforces this automatically.
Q2A bank's loan approval model achieved 96% accuracy but is being criticised. What could be wrong?+
Answer
96% accuracy sounds excellent but can be deeply misleading when classes are imbalanced — the most common scenario in credit risk.
Class imbalance problem: If only 4% of loan applications historically defaulted, a model that predicts "never default" for every single applicant achieves 96% accuracy without learning anything useful. This model would approve every bad loan, causing enormous financial loss.
Better metrics: Precision (of loans predicted as default, how many actually defaulted?), Recall (of actual defaults, how many did we catch?), F1 score (harmonic mean of precision and recall), and AUC-ROC (measures ranking ability; robust to class imbalance).
Additional concerns: Non-representative test set (e.g., COVID-era data with abnormal default patterns). Indian regulatory compliance (RBI guidelines) requires model interpretability and fairness audits — a black-box model may not satisfy compliance regardless of its accuracy figure.
Q3What is the difference between a validation set and a test set? Can you use the test set to select the best model?+
Answer
Validation set: Held out from training, used during development to compare models and tune hyperparameters. You may examine validation performance many times across many experiments.
Test set: Completely held-out data simulating future production inputs. Evaluated exactly once after all model development decisions are finalised, to report final generalisation performance.
Can you use the test set to select the best model? No. Doing so causes "implicit overfitting to the test set." Each time you evaluate multiple models on the test set and pick the winner, you are effectively using the test set as a selection device. The winning model's test performance will be optimistically biased — it "won" partly due to luck on those specific test examples. With many models evaluated, the best test score will appear impressive purely by chance.
Correct workflow: Train on train set → Compare/tune using validation set (or k-fold CV) → Pick one final model → Report test set performance exactly once. The test set is touched once, its result reported, and never used for further decisions.
Supervised Learning
Linear Regression
The cornerstone of predictive modelling — fitting a straight line through data to forecast continuous outcomes with mathematical precision.
🎯 Intuition First
Picture a cloud of dots — fabric sold on one axis, revenue on the other. Linear regression draws the one straight line that sits as close as possible to every dot at once. Prediction is then just reading off it: hand it a new x, it gives back the y on the line. Everything below — cost function, gradient, normal equation — is simply how the machine finds that best line so you don't have to eyeball it.
The Core Idea
Linear regression assumes a linear relationship between one or more input features X and a continuous output y. Given n training samples, we want to find parameters θ (weights) such that ŷ ≈ y.
Simple linear regression (one feature):
ŷ = θ₀ + θ₁x · where θ₀ = intercept (bias), θ₁ = slope (weight)
Partial derivatives and the chain rule are exactly what's needed to follow this derivation line by line — see the Calculus for ML prerequisite page for the full first-principles walkthrough (derivatives, the gradient, and why "step opposite the gradient" is the core mechanism behind every optimiser in this course).
This is exactly the gradient the animation below uses at every step. Because J(θ) is a convex quadratic bowl (no local minima), gradient descent with a suitable learning rate is guaranteed to converge to the global optimum.
The Normal Equation — A Closed-Form Alternative
For linear regression specifically, we don't need to iterate at all. Setting ∇J(θ) = 0 and solving directly:
from sklearn.linear_model import LinearRegression
from sklearn.model_selection import train_test_split
from sklearn.metrics import mean_squared_error, r2_score
import numpy as np
X_feat = X_raw.reshape(-1, 1)
X_train, X_test, y_train, y_test = train_test_split(
X_feat, y, test_size=0.2, random_state=42)
model = LinearRegression()
model.fit(X_train, y_train)
y_pred = model.predict(X_test)
print(f"Intercept : {model.intercept_:.2f}")
print(f"Coefficient: {model.coef_[0]:.4f}")
print(f"Test RMSE : {np.sqrt(mean_squared_error(y_test, y_pred)):.2f}")
print(f"Test R² : {r2_score(y_test, y_pred):.4f}")
Intercept : 2.14
Coefficient: 1.1583
Test RMSE : 0.52
Test R² : 0.9998
Reading an OLS Summary Table (statsmodels)
LinearRegression from scikit-learn hands you coefficients and predictions but stays silent on how confident you should be in them. statsmodels' OLS class fits the identical line but also runs the full classical-statistics machinery behind it — standard errors, t-tests, an F-test — and prints it all in one readable table. This is the table you'll see quoted in almost every regression-based research paper or business report.
Mehta Textiles wants to explain monthly revenue using two drivers — advertising spend and store footfall:
import numpy as np
import pandas as pd
import statsmodels.api as sm
np.random.seed(42)
n = 60
ad_spend = np.random.uniform(2, 20, n) # ₹ lakh / month
footfall = np.random.uniform(500, 5000, n) # customers / month
revenue = 8 + 1.35*ad_spend + 0.006*footfall + np.random.normal(0, 3, n)
df = pd.DataFrame({'ad_spend_lakh': ad_spend, 'footfall': footfall, 'revenue_lakh': revenue})
X = sm.add_constant(df[['ad_spend_lakh', 'footfall']])
model = sm.OLS(df['revenue_lakh'], X).fit()
print(model.summary())
93.7% of the variance in revenue is explained by ad spend + footfall together.
Adj. R-squared
R² adjusted for the number of predictors (2 here) — penalises adding useless features. Always ≤ R²; a big gap between the two warns of overfitting with too many predictors for too little data.
F-statistic & Prob (F-statistic)
Tests the joint null hypothesis "all coefficients are zero." F=425.0 with Prob≈5.63e-35 (essentially 0) says the model as a whole is overwhelmingly significant — not a fluke.
Log-Likelihood
How probable the observed data is under the fitted model (higher = better fit). Only meaningful when comparing models on the same data — not interpretable on its own.
AIC / BIC
Log-Likelihood converted into a model-comparison score that penalises extra parameters — lower is better. BIC penalises complexity more harshly than AIC. Used to pick between competing models (e.g. 2-predictor vs. 5-predictor), never as a stand-alone number.
coef / std err / t / P>|t|
Per-feature version of the same idea: footfall's coefficient (0.0063) is 22.4 standard errors from zero (t=22.421), so P>|t| rounds to 0.000 — footfall is a statistically significant driver of revenue on its own, holding ad spend fixed.
Cond. No.
A multicollinearity smoke-alarm — large values (here 8.96e+03) suggest checking predictor correlations. The dedicated diagnostic for this, Variance Inflation Factor (VIF), is covered in depth on the Multiple Regression page.
🔗 sklearn vs. statsmodels
Use scikit-learn when the goal is prediction and the model feeds into a pipeline. Use statsmodels when the goal is inference — explaining which features matter and how confident you are — which is why business and research reports almost always quote a statsmodels-style table rather than raw sklearn coefficients.
Key Assumptions (LINE)
Assumption
What it means
How to check
Linearity
y is a linear function of X
Scatter plot, residuals vs fitted
Independence
Residuals are uncorrelated
Durbin-Watson test
Normality
Residuals ~ N(0, σ²)
QQ-plot, Shapiro-Wilk
Equal variance
Homoscedasticity
Breusch-Pagan test
Regularisation
Technique
Penalty added to J(θ)
Effect
Use when
Ridge (L2)
λ Σ θⱼ²
Shrinks all weights
Multicollinearity
Lasso (L1)
λ Σ |θⱼ|
Zeros out irrelevant features
Feature selection
ElasticNet
λ₁ L1 + λ₂ L2
Balance of both
Many correlated features
When to Use / Avoid
✓ Use Linear Regression when
Relationship is genuinely linear
Interpretability is critical (banking, regulatory)
Baseline model before complex algorithms
Small dataset with few features
HDFC Bank loan amount prediction
✗ Avoid when
Non-linear patterns exist
Outliers are heavy (use Huber regression)
Features are strongly multicollinear (use Ridge)
Target is categorical (use logistic regression)
Complex interactions between features
A Robust Alternative: Least Absolute Deviation (LAD) Regression
Ordinary Least Squares minimises squared error — which means a single extreme outlier gets squared too, and can pull the whole fitted line toward it disproportionately. Least Absolute Deviation regression swaps the loss function from squared to absolute error:
This single change in the loss function — nothing else about the model — makes the fit dramatically more robust to outliers, since an outlier's contribution to the loss grows linearly instead of quadratically with its error. The trade-off: LAD's loss isn't differentiable at zero error, so it can't be solved with the closed-form Normal Equation and needs an iterative solver (e.g., statsmodels' quantile regression at the median, which is mathematically equivalent to LAD).
import statsmodels.formula.api as smf
import pandas as pd
import numpy as np
# Ahmedabad apartment prices with a few data-entry-error outliers
np.random.seed(2)
area = np.random.randint(500,2500,200)
price = 0.08*area + np.random.normal(0,5,200) + 40
price[5], price[40] = 900, 850# two badly mis-entered rows
df = pd.DataFrame({'area':area, 'price':price})
ols = smf.ols('price ~ area', data=df).fit()
lad = smf.quantreg('price ~ area', data=df).fit(q=0.5) # median regression = LAD
print(f"OLS slope: {ols.params['area']:.4f} (distorted by the 2 outliers)")
print(f"LAD slope: {lad.params['area']:.4f} (much closer to the true ₹0.08/sqft)")
OLS slope: 0.1187 (distorted by the 2 outliers)
LAD slope: 0.0819 (much closer to the true ₹0.08/sqft)
LAD is a special case of quantile regression at the 50th percentile (the median) — a useful thing to know, since it means the same machinery generalises to predicting any quantile (e.g., the 90th percentile for conservative capacity planning), not just the central tendency.
🔗 Real-World Link — Salary Prediction
A 30-row dataset of years-of-experience vs. salary fits a single-feature line with R²=0.957 — a rare real dataset clean enough to show the textbook case with almost no noise. See the case study → ·
❓ Conceptual Q&A
Why does gradient descent require feature scaling but the Normal Equation does not?+
Gradient descent is an iterative optimiser that follows the loss surface contours. When features have very different scales (e.g., salary in lakhs vs. age in years), the loss bowl becomes elongated — gradient steps bounce along the steep axis and crawl along the flat one, converging very slowly or oscillating. Scaling makes the bowl nearly circular so equal-sized steps reach the minimum efficiently.
The Normal Equation is a direct algebraic formula (θ* = (XᵀX)⁻¹Xᵀy). It computes the exact solution in one step regardless of scale — the matrix operations handle the geometry automatically. However, inverting XᵀX becomes numerically unstable when features are on wildly different scales, so in practice scaling is still recommended for numerical stability even with the Normal Equation.
What is R² and what does an R² of 0.85 actually mean?+
R² (coefficient of determination) measures the proportion of variance in y explained by the model: R² = 1 − SS_res/SS_tot. SS_res is the sum of squared residuals; SS_tot is the total variance around the mean. An R² of 0.85 means the model explains 85% of the variance in revenue — the remaining 15% is due to factors not captured by fabric quantity alone (raw material costs, market demand, export tariffs, etc.).
Important caveat: R² always increases (or stays equal) when you add more features, even noise. Adjusted R² penalises for the number of predictors: R²_adj = 1 − (1−R²)(n−1)/(n−p−1). Always compare adjusted R² when comparing models with different numbers of features.
Mehta Textiles finds their model predicts well for low output but badly for high output. What is the likely issue and fix?+
This is a classic heteroscedasticity problem — the residual variance is not constant but grows with the predicted value (a "funnel" shape in residual plots). This violates the E assumption of LINE.
Common fixes: (1) Apply a log transformation to y (predict log-revenue, then exponentiate predictions) — this compresses large values and stabilises variance. (2) Use Weighted Least Squares, assigning lower weights to observations with higher variance. (3) Use a Generalised Linear Model with a Gamma distribution, which naturally handles right-skewed positive targets. In practice, log(y) is the fastest first fix for revenue/sales data.
Tree-Based Models
Decision Trees
A hierarchical, interpretable model that makes predictions by learning a sequence of if-else rules derived from the training data.
🎯 Intuition First
Every diagnosis, every fraud call, every loan decision you've ever seen a human expert make is really just a chain of yes/no questions: Is the income above ₹40 lakh? If yes, is the CIBIL score above 700? If yes, approve. A decision tree learns exactly that chain of questions directly from the data — and unlike almost every other model in this course, you can literally read the resulting rules out loud in plain language. The trick is that the tree doesn't guess which question to ask first: at every node it exhaustively evaluates every feature at every threshold and picks the one that best separates the two classes. That single "best split" idea, applied recursively, is the entire algorithm.
📋 Real-World Case Study — Transaction Fraud Detection
Payment fraud teams favour decision trees precisely because of their interpretability requirement: when a transaction is auto-declined, compliance and customer support need a traceable explanation, not a black-box score. A tree that splits on "amount > ₹40,000 AND merchant_category = electronics AND time = late_night" gives an auditable, plain-language reason for the decline — a genuine business requirement in regulated financial contexts that pushes teams toward trees (or tree ensembles with SHAP explanations) over harder-to-explain alternatives.
Tree Structure
A decision tree partitions the feature space recursively. Each internal node tests a feature; each branch represents a threshold split; each leaf gives a prediction (class label or mean value). Trees are fully interpretable — you can trace exactly why a prediction was made.
Splitting Criteria
Criterion
Formula
Used for
Gini Impurity
1 − Σ pᵢ²
Classification (CART)
Entropy
−Σ pᵢ log₂(pᵢ)
Classification (ID3, C4.5)
Information Gain
H(parent) − Σ wᵢH(child)
Classification
MSE / Variance reduction
Var(y) before − Σ wᵢVar(yᵢ)
Regression
Worked Numeric Example — Computing a Gini Split by Hand
A parent node has 10 HDFC loan applicants: 6 approved, 4 rejected. Splitting on "Income ≥ ₹40L?" sends 5 applicants left (4 approved, 1 rejected) and 5 right (2 approved, 3 rejected):
The CART algorithm (implemented in the _best_split method below) evaluates this Gini Gain for every feature at every candidate threshold, and greedily picks whichever split maximises it — exactly the loop you can trace in the from-scratch code.
Synchronised View — Feature Space ↔ Tree Structure
The animation below runs the two views side-by-side on a real (sklearn-verified, depth-2, 24-applicant) HDFC loan tree. On the left, each greedy split appears in the 2D feature space; on the right, the corresponding tree node grows in step with it. Watch how each cut in the feature space becomes exactly one internal node in the tree — the tree diagram is just a hierarchical index over the rectangular partitions on the scatter, not a separate model.
From Scratch — CART Algorithm
# Decision Tree CART from scratchimport numpy as np
from collections import Counter
class Node:
def __init__(self, feat=None, thr=None, left=None, right=None, val=None):
self.feat=feat; self.thr=thr; self.left=left; self.right=right; self.val=val
class DecisionTreeClassifier:
def __init__(self, max_depth=None, min_samples_split=2):
self.max_depth=max_depth; self.min_samples_split=min_samples_split
def _gini(self, y):
n=len(y); counts=Counter(y)
return1 - sum((c/n)**2for c in counts.values())
def _best_split(self, X, y):
best_gain, best_feat, best_thr = -np.inf, None, None
g_parent = self._gini(y)
for feat in range(X.shape[1]):
thresholds = np.unique(X[:, feat])
for thr in thresholds:
l, r = y[X[:,feat]<=thr], y[X[:,feat]>thr]
if len(l)==0or len(r)==0: continue
gain = g_parent - (len(l)/len(y))*self._gini(l) - (len(r)/len(y))*self._gini(r)
if gain > best_gain: best_gain,best_feat,best_thr = gain,feat,thr
return best_feat, best_thr
def _build(self, X, y, depth=0):
if (len(set(y))==1or len(y)or
(self.max_depth and depth>=self.max_depth)):
return Node(val=Counter(y).most_common(1)[0][0])
feat, thr = self._best_split(X, y)
if feat isNone: return Node(val=Counter(y).most_common(1)[0][0])
mask = X[:,feat] <= thr
return Node(feat=feat, thr=thr,
left=self._build(X[mask], y[mask], depth+1),
right=self._build(X[~mask], y[~mask], depth+1))
def fit(self, X, y): self.root_=self._build(np.array(X), np.array(y))
def _predict_one(self, x, node):
if node.val is notNone: return node.val
return (self._predict_one(x, node.left) if x[node.feat]<=node.thr
else self._predict_one(x, node.right))
def predict(self, X): return np.array([self._predict_one(x, self.root_) for x in X])
# Test on HDFC loan data
np.random.seed(42)
income=np.random.uniform(15,75,200); cibil=np.random.uniform(540,790,200)
y=(((income>40)&(cibil>660))|((income>55))).astype(int)
X=np.column_stack([income,cibil])
from sklearn.model_selection import train_test_split
X_tr,X_te,y_tr,y_te=train_test_split(X,y,test_size=0.2,random_state=0)
dt=DecisionTreeClassifier(max_depth=4); dt.fit(X_tr,y_tr)
print(f"Scratch DT accuracy: {np.mean(dt.predict(X_te)==y_te):.3f}")
from sklearn.tree import DecisionTreeClassifier as SKDT
skdt=SKDT(max_depth=4); skdt.fit(X_tr,y_tr)
print(f"sklearn DT accuracy: {skdt.score(X_te,y_te):.3f}")
Grow full tree, then prune branches with ccp_alpha
ccp_alpha (tune via CV)
❓ Conceptual Q&A
What is information gain and how does it differ from Gini impurity?+
Both measure node purity — how mixed the classes are. Information Gain uses entropy (from information theory): H = −Σ pᵢ log₂(pᵢ). A pure node (all same class) has entropy 0; maximum disorder has entropy 1 (for binary). Information Gain = parent entropy − weighted average child entropy. Gini impurity = 1 − Σ pᵢ². A pure node has Gini 0; maximum impurity is 0.5 (binary). In practice, they produce nearly identical trees. Gini is slightly faster to compute (no logarithm) and is the sklearn default. Information Gain can be biased toward features with many unique values; Gain Ratio (C4.5) corrects this by normalising by the split information.
Why do decision trees overfit so severely, and how do max_depth and min_samples_leaf help?+
An unconstrained decision tree grows until every leaf is pure — it will create separate branches for every training point if needed. This memorises noise: a single outlier (e.g., an Ahmedabad apartment with unusually high price due to a unique buyer) gets its own leaf. On new data, this specific rule fails. max_depth prevents deep trees from learning highly specific patterns — a depth-3 tree can only model 8 different prediction regions, forcing generalisation. min_samples_leaf requires each leaf to have at least n training samples — a node with 1 sample is suspicious and likely noise; requiring 5–20 samples per leaf ensures each rule is supported by real patterns. Together, they implement pre-pruning. Cross-validate to find the sweet spot where training and validation accuracy are close.
A decision tree for Tata Motors warranty claim prediction has 100% training accuracy but 60% test accuracy. What would you do?+
This is severe overfitting — the tree has memorised the training set. Three-step fix: (1) Constrain growth: set max_depth=4–8, min_samples_leaf=10–50, min_samples_split=20 — use cross-validation to tune. (2) Cost-complexity pruning: call dt.cost_complexity_pruning_path(X_tr, y_tr) to get ccp_alpha candidates, then cross-validate to find the alpha that maximises validation accuracy. (3) Ensemble: switch to Random Forest or Gradient Boosting — these average many trees, dramatically reducing variance. In most real-world cases, a well-tuned Random Forest will outperform a single decision tree with no additional effort.
Unsupervised Learning · New
Gaussian Mixture Models & the EM Algorithm
K-Means forces every point into exactly one cluster with a hard boundary. GMM asks a softer, more honest question: what's the probability this point belongs to each cluster?
Hard vs Soft Clustering
K-Means assigns each point to its single nearest centroid — a hard, all-or-nothing decision. A Gaussian Mixture Model instead assumes the data was generated by a weighted mixture of K Gaussian distributions, and computes each point's probability of membership in every cluster:
πₖ is the mixture weight (prior probability) of cluster k, and 𝒩(x|μₖ,Σₖ) is the Gaussian density with mean μₖ and covariance Σₖ. Critically, each cluster gets its own covariance matrix — meaning GMM can naturally represent elongated or tilted elliptical clusters, something K-Means' pure-distance assignment cannot.
The EM Algorithm — Fitting a GMM
The mixture weights, means, and covariances can't be solved in closed form because we don't observe which cluster generated each point — that's a hidden (latent) variable. Expectation-Maximization solves this by alternating between two steps until convergence:
E-step (Expectation): Given the current parameters, compute the "responsibility" γᵢₖ — the probability that cluster k generated point i, for every point and every cluster (soft assignment)
M-step (Maximization): Given the responsibilities, re-estimate πₖ, μₖ, Σₖ as the responsibility-weighted mean/covariance of all points, for each cluster
Repeat until the log-likelihood stops improving meaningfully
This is precisely "soft K-Means": the E-step is analogous to K-Means' assignment step (but probabilistic, not hard), and the M-step is analogous to K-Means' centroid update (but weighted by responsibility, not a hard 0/1 membership). Each EM iteration is guaranteed to never decrease the data log-likelihood — it converges, though possibly to a local optimum, exactly like K-Means' own convergence guarantee.
Watch EM Converge — Ellipses Finding the Clusters
A real EM run on two elongated, correlated Gaussian blobs (verified against scikit-learn's GaussianMixture). Each iteration redraws the two fitted Gaussians as 2-sigma ellipses and recolours every point by its soft responsibility γ (blue ↔ orange blend). Starting from a deliberately off-centre, circular guess, watch the ellipses stretch, rotate, and slide into place while the log-likelihood climbs at every step — the M-step made visible.
Code — GMM on Mumbai Customer Spending, Compared to K-Means
import numpy as np
from sklearn.mixture import GaussianMixture
from sklearn.cluster import KMeans
# Customer segments with genuinely different (elongated) spending covariance shapes
np.random.seed(6)
budget = np.random.multivariate_normal([20,8], [[15,8],[8,6]], 150) # correlated spread
premium = np.random.multivariate_normal([70,25], [[40,-15],[-15,20]], 100) # negatively correlated
occasional= np.random.multivariate_normal([35,40], [[10,0],[0,35]], 120)
X = np.vstack([budget, premium, occasional])
gmm = GaussianMixture(n_components=3, covariance_type='full', random_state=42).fit(X)
km = KMeans(n_clusters=3, n_init=10, random_state=42).fit(X)
# GMM gives a full probability distribution over clusters per point, not just a label
sample_point = X[0:1]
probs = gmm.predict_proba(sample_point)
print(f"GMM soft membership for one point: {np.round(probs[0], 3)}")
print(f"K-Means hard label for same point: cluster {km.predict(sample_point)[0]}")
print(f"\nGMM BIC = {gmm.bic(X):.1f} (lower is better, penalises complexity)")
print(f"GMM converged in {gmm.n_iter_} EM iterations")
GMM soft membership for one point: [0.891 0.021 0.088]
K-Means hard label for same point: cluster 0
GMM BIC = 5218.4 (lower is better, penalises complexity)
GMM converged in 11 EM iterations
The point is 89.1% likely to belong to cluster 0, but genuinely has an 8.8% chance of belonging to cluster 2 — a customer near a segment boundary. K-Means can only ever report "cluster 0", discarding this ambiguity entirely.
Choosing K — the Bayesian Information Criterion (BIC)
Unlike K-Means' elbow/silhouette methods, GMM has a natural model-selection tool: BIC trades off how well the mixture fits the data (log-likelihood) against model complexity (number of parameters, which grows with K and with covariance flexibility):
\mathrm{BIC} = -2\ln(\hat{L}) + p\ln(n) \qquad \text{where } p = \text{number of free parameters},\ n = \text{sample size}
bics = []
for k in range(1, 8):
g = GaussianMixture(n_components=k, covariance_type='full', random_state=42).fit(X)
bics.append(g.bic(X))
print(f"k={k} BIC={g.bic(X):8.1f}")
best_k = np.argmin(bics) + 1
print(f"\nBest k by BIC: {best_k}")
k=1 BIC= 6104.2
k=2 BIC= 5602.7
k=3 BIC= 5218.4 ← minimum
k=4 BIC= 5286.9
k=5 BIC= 5359.1
k=6 BIC= 5441.8
k=7 BIC= 5527.3
Best k by BIC: 3
Click anywhere on the plot to place a new Mumbai customer (Monthly Spend vs Visits/Month) and see their true responsibility γ across the three segments below — computed from the same three Gaussian components (with their real, correlated covariance shapes) used to generate the data above. Compare this to what a hard nearest-centroid (K-Means-style) rule would have said instead.
Click the plot to test any other point
❓ Conceptual Q&A
In what precise sense is K-Means "a special case of" the EM algorithm used for GMM?+
K-Means' two-step loop (assign each point to its nearest centroid, then recompute centroids as the mean of assigned points) maps directly onto E-step/M-step. K-Means emerges as the limiting case of GMM's EM when: (1) every cluster's covariance is constrained to be the identity matrix scaled by a shared, shrinking variance (isotropic, spherical, identical across clusters), and (2) the soft responsibilities γᵢₖ are forced to become "hard" — 1 for the single most probable cluster, 0 for all others — rather than genuine probabilities. Under these two restrictions, GMM's E-step reduces exactly to "assign to nearest centroid" (since with equal spherical covariances, the highest-probability cluster is simply the nearest one in Euclidean distance) and the M-step reduces exactly to "recompute centroid as the mean of hard-assigned points."
This is why K-Means inherits the same weaknesses as EM (sensitivity to initialisation, convergence to local optima, needing K specified in advance) — it's not a fundamentally different algorithm, it's EM with its most flexible modelling choices (soft assignment, per-cluster covariance shape) stripped away for speed and simplicity.
Why can BIC select the number of clusters for GMM, but there's no equally principled likelihood-based way to do this for K-Means?+
GMM is a genuine generative probabilistic model — it defines p(x), the actual probability density of the data, as a function of its parameters. This makes a proper likelihood function L(data | parameters) well-defined, and BIC directly penalises that likelihood by model complexity (number of parameters) to avoid simply rewarding ever-larger K, which would otherwise always improve raw likelihood. This is the same principled bias-variance trade-off machinery used in AIC/BIC model selection throughout statistics.
K-Means, by contrast, doesn't define any probability distribution at all — it only minimises within-cluster sum-of-squared-distances (WCSS), a geometric quantity, not a likelihood. WCSS mechanically decreases every time K increases (more clusters can only ever fit the data at least as well geometrically), with no natural complexity penalty built in — which is exactly why K-Means needs heuristic tools like the elbow method (subjective visual judgment) or silhouette score (a different geometric heuristic) rather than a clean statistical criterion like BIC.
A Bengaluru startup wants to segment users into "definitely Segment A", "definitely Segment B", and "ambiguous, needs manual review" for a targeted campaign. Which clustering approach fits this requirement, and how would you implement the three-way split?+
GMM is the natural fit precisely because it outputs calibrated per-point cluster probabilities rather than a forced hard label. K-Means simply cannot express "ambiguous" — every point gets exactly one cluster assignment regardless of how close it sits to a genuine boundary between segments.
Implementation: fit a GMM with the desired number of segments, call predict_proba(X) to get each user's full probability vector over clusters, then apply a confidence threshold — for example, if the maximum probability across clusters exceeds 0.85, assign the user confidently to that segment; otherwise (max probability below 0.85, meaning the top two clusters are both plausible), route the user to a "needs manual review" bucket. This threshold is a genuine business decision (how much ambiguity tolerance is acceptable for the campaign) rather than an algorithmic artefact, and it's only possible to make this distinction at all because GMM exposes real probabilities rather than K-Means' all-or-nothing labels.
Dimensionality Reduction
Principal Component Analysis
PCA finds the orthogonal axes of maximum variance in high-dimensional data — compressing features while preserving the most information.
Motivation
High-dimensional datasets suffer from: the curse of dimensionality (distances concentrate), visualisation difficulty, and redundant correlated features. PCA transforms the original p features into k uncorrelated principal components (k ≪ p) that capture the most variance.
Step 3 is the crux of PCA and leans entirely on eigenvalues/eigenvectors from linear algebra — see the Linear Algebra for ML prerequisite page for the full derivation from matrices and vectors up through eigendecomposition, including this exact covariance-matrix example worked out with real numbers. Core intuition: eigenvectors of the covariance matrix are the directions of maximum spread, and their eigenvalues tell you exactly how much variance lies along each one.
⚙ Deriving Why Eigenvectors — The Full Argument
Step 3 isn't just asserted — it falls directly out of solving PCA's actual objective. The first principal component is the unit vector w (‖w‖=1) that maximises the variance of the data projected onto it:
Maximise wᵀΣw subject to wᵀw = 1 using a Lagrange multiplier:
L(w,\lambda) = w^\mathsf{T}\Sigma w - \lambda(w^\mathsf{T}w - 1) \;\;\to\;\; \frac{\partial L}{\partial w} = 2\Sigma w - 2\lambda w = 0 \;\;\to\;\; \Sigma w = \lambda w
The critical points of the variance are exactly the eigenvectors of Σ — that's the whole reason eigendecomposition appears in Step 3. Plugging Σw=λw back into the objective gives wᵀΣw = wᵀ(λw) = λ(wᵀw) = λ, so the variance captured along direction w equals its eigenvalue. The maximum over all unit vectors is therefore the largest eigenvalue λ₁, achieved at its eigenvector v₁ — PC1. Each subsequent PC repeats this maximisation subject to being orthogonal to all previous PCs, which is exactly why the remaining eigenvectors (in descending eigenvalue order) are PC2, PC3, and so on.
🔁 Two Equivalent Views of PCA
Everything above frames PCA as maximising variance. There's a second, completely equivalent way to arrive at the same eigenvectors: minimising reconstruction error — finding the k-dimensional subspace that, when you project the data onto it and reconstruct back to the original space, loses the least information (smallest mean squared error). The Eckart–Young theorem guarantees these two objectives have identical solutions: the top-k eigenvectors of Σ. This is also exactly what the SVD of X_c computes directly, which is why scikit-learn's PCA is implemented via SVD internally rather than by literally eigendecomposing the covariance matrix.
from sklearn.decomposition import PCA
from sklearn.preprocessing import StandardScaler
X_s = StandardScaler().fit_transform(X)
pca_full = PCA()
pca_full.fit(X_s)
# Choose k where cumulative variance ≥ 95%
cumvar = pca_full.explained_variance_ratio_.cumsum()
k95 = (cumvar >= 0.95).argmax() + 1
print(f"Components needed for 95% variance: {k95}")
# Rebuild the pipeline with PCAfrom sklearn.pipeline import Pipeline
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import cross_val_score
y_dummy = (X[:,0] > 5).astype(int)
pipe = Pipeline([('scaler', StandardScaler()), ('pca', PCA(n_components=k95)),
('clf', LogisticRegression())])
cv = cross_val_score(pipe, X, y_dummy, cv=5)
print(f"CV accuracy with PCA preprocessing: {cv.mean():.3f}")
Components needed for 95% variance: 4
CV accuracy with PCA preprocessing: 0.951
Supervised Alternative: Linear & Quadratic Discriminant Analysis
PCA is unsupervised dimensionality reduction — it finds directions of maximum variance with no knowledge of class labels, which can occasionally discard a low-variance direction that actually separates classes perfectly. Linear Discriminant Analysis (LDA) instead uses the labels directly, finding the projection that maximises class separability:
This ratio is maximised when classes are pushed far apart (large S_B) while each class stays tightly clustered internally (small S_W) — precisely the notion of "good separation" a classifier needs. LDA assumes all classes share the same covariance structure, giving it a linear decision boundary. Quadratic Discriminant Analysis (QDA) relaxes that assumption, letting each class have its own covariance matrix — at the cost of more parameters to estimate, needing more data per class to avoid overfitting.
from sklearn.discriminant_analysis import LinearDiscriminantAnalysis, QuadraticDiscriminantAnalysis
from sklearn.model_selection import cross_val_score
# HDFC loan approval — same feature set as the Decision Trees chapter
lda = LinearDiscriminantAnalysis()
qda = QuadraticDiscriminantAnalysis()
for name, model in [('LDA',lda), ('QDA',qda), ('PCA+LogReg',pipe)]:
scores = cross_val_score(model, X, y_dummy, cv=5)
print(f"{name:12s} CV accuracy: {scores.mean():.3f}")
# LDA can ALSO be used purely for dimensionality reduction, like PCA —# but projects onto at most (n_classes - 1) axes that maximise separability
LDA edges out PCA+LogisticRegression here precisely because it optimises directly for separability rather than variance — but this comes at a cost: LDA needs the labels, so it can never be used in a purely unsupervised setting (e.g., exploratory visualisation of unlabelled data), which is exactly where PCA remains the right tool.
⚠ Advanced: Factor Analysis
PCA finds directions of maximum variance with no underlying statistical model of why the data looks the way it does. Factor Analysis instead assumes each observed feature is a linear combination of a small number of unobserved (latent) factors plus feature-specific noise:
This is a subtle but important distinction from PCA: Factor Analysis explicitly separates "shared signal" (the factors) from "noise unique to each feature," while PCA's components can absorb both indiscriminately. This makes Factor Analysis the traditional tool of choice in psychometrics and social science (e.g., inferring a handful of latent "customer satisfaction dimensions" from dozens of survey questions, each with its own measurement noise).
from sklearn.decomposition import FactorAnalysis, PCA
# 12 survey questions assumed to reflect 3 underlying satisfaction dimensions
fa = FactorAnalysis(n_components=3, random_state=42).fit(X)
# noise_variance_ is Factor Analysis' key output PCA has no equivalent for —# per-feature noise NOT explained by the shared latent factors
print(f"Per-feature noise variance: {np.round(fa.noise_variance_, 3)}")
⚠ Advanced: Independent Component Analysis (ICA)
PCA finds uncorrelated components (zero covariance). ICA searches for a stronger condition — statistically independent components — making it the standard tool for blind source separation: recovering original independent signals that have been linearly mixed together, without knowing the mixing process in advance.
The classic illustration is the "cocktail party problem": several microphones each record a mixture of multiple people talking simultaneously; ICA can separate the mixed recordings back into each individual speaker's voice, using only statistical independence — no information about microphone placement or voice characteristics required. Since this is a purely mathematical/synthetic illustration of the mixing process itself, the example below uses three genuinely independent synthetic signals rather than a forced business framing.
import numpy as np
from sklearn.decomposition import FastICA, PCA
# Three genuinely independent, non-Gaussian source signals
time = np.linspace(0, 8, 2000)
s1 = np.sin(2 * time) # sine wave
s2 = np.sign(np.sin(3 * time)) # square wave
s3 = np.random.RandomState(42).laplace(size=2000) # noisy Laplace source
S = np.c_[s1, s2, s3]
S /= S.std(axis=0)
# Mix the three sources together with an arbitrary 3x3 mixing matrix
A = np.array([[1,1,1], [0.5,2,1.0], [1.5,1.0,2.0]])
X_mixed = S.dot(A.T) # only X_mixed would be "observed" in a real cocktail-party recording
ica = FastICA(n_components=3, random_state=42, whiten='unit-variance').fit(X_mixed)
S_ica = ica.transform(X_mixed)
pca = PCA(n_components=3, random_state=42).fit(X_mixed)
S_pca = pca.transform(X_mixed)
# Best-match correlation between each TRUE source and its recovered version
corr_ica = np.abs(np.corrcoef(S.T, S_ica.T)[:3, 3:]).max(axis=1)
corr_pca = np.abs(np.corrcoef(S.T, S_pca.T)[:3, 3:]).max(axis=1)
print(f"ICA mean recovery correlation: {corr_ica.mean():.4f}")
print(f"PCA mean recovery correlation: {corr_pca.mean():.4f}")
Output
ICA mean recovery correlation: 0.9991
PCA mean recovery correlation: 0.7280
ICA recovers the three original, independently-generated signals almost perfectly (mean correlation 0.9991) from nothing but the mixed observations — it never sees S, A, s1, s2, or s3 directly. Plain PCA, run on the exact same mixed input, manages only 0.7280: PCA can find the directions of maximum variance in the mixture, but decorrelating the mixed signals is not the same as recovering statistically independent ones — a sine wave and a square wave can easily be made uncorrelated (zero covariance) while remaining trivially predictable from one another, which is precisely the kind of residual dependency PCA cannot detect but ICA is built to exploit.
⚠ PCA vs ICA — Not Interchangeable
PCA answers "what directions capture the most variance?" — useful for compression and denoising. ICA answers "what original independent signals were mixed together?" — useful for un-mixing. Using PCA when you actually need source separation (or vice versa) produces components that are mathematically valid but meaningless for the actual question being asked. The 0.9991 vs. 0.7280 recovery gap above is the direct, measured consequence of that mismatch.
PCA, Factor Analysis and ICA all find a linear transformation of the original features. Some datasets, though, sit on a curved surface — the classic teaching example is the "Swiss roll," a flat 2D sheet rolled up into 3D space. A straight-line projection can't unroll it: two points genuinely far apart along the sheet can end up right next to each other in 3D purely because the roll happens to curl them close together. Since this is a purely mathematical illustration of manifold shape, the example below uses a synthetic dataset rather than a forced business framing.
Isomap and Multidimensional Scaling (MDS) are two classic non-linear ("manifold learning") alternatives built for exactly this situation:
Method
Core idea
Best for
MDS
Directly preserves pairwise straight-line distances between all points as faithfully as possible in the low-dimensional space.
General-purpose distance-preserving layouts; simplest to explain.
Isomap
Preserves geodesic distance — distance measured along the manifold's surface via a neighbour graph — rather than straight-line distance through the ambient space.
Data known to lie on a curved surface, like the Swiss roll.
The standard way to judge these embeddings is trustworthiness: for each point, what fraction of its nearest neighbours in the low-dimensional embedding were also its nearest neighbours in the original space? 1.0 means neighbourhoods are perfectly preserved.
from sklearn.datasets import make_swiss_roll
from sklearn.manifold import Isomap, MDS, trustworthiness
from sklearn.decomposition import PCA
X, color = make_swiss_roll(n_samples=800, noise=0.05, random_state=42)
X_iso = Isomap(n_neighbors=10, n_components=2).fit_transform(X)
X_mds = MDS(n_components=2, random_state=42, normalized_stress='auto').fit_transform(X)
X_pca = PCA(n_components=2, random_state=42).fit_transform(X)
for name, X_low in [('Isomap', X_iso), ('MDS', X_mds), ('PCA', X_pca)]:
print(f"{name:8s} trustworthiness: {trustworthiness(X, X_low, n_neighbors=10):.4f}")
On this Swiss roll, Isomap's geodesic-aware approach recovers the true neighbourhood structure almost perfectly (0.9992). Generic MDS (0.9240) and even plain linear PCA (0.9396) preserve straight-line distance/variance well enough to score respectably too — neither collapses — but neither one explicitly follows the manifold's curl the way Isomap does, which is precisely why Isomap exists as a distinct tool for data with known non-linear structure rather than a strict upgrade over MDS in every case.
🔗 Real-World Link — Instagram Recommendations
The same real Instagram engagement dataset used for reach analysis, viewed differently here: reducing 11 numeric engagement metrics to a handful of principal components to find posts with a similar underlying engagement "shape." See the case study → ·
❓ Conceptual Q&A
Why must you standardise features before PCA?+
PCA finds directions of maximum variance. If features have different scales (salary in lakhs vs. age in years), the covariance matrix is dominated by high-variance features regardless of their actual importance. The first principal component would essentially just point in the direction of salary, ignoring all other variables. StandardScaler (zero mean, unit variance) ensures each feature contributes equally to the covariance matrix, so PCA finds genuinely informative directions rather than scale artifacts. The exception: if you deliberately want to weight features by their variance (e.g., in signal processing), you may skip scaling.
How many components should you retain and what is the scree plot?+
Common strategies: (1) Cumulative variance threshold — retain k components that together explain ≥ 90% or 95% of total variance. Most standard for downstream ML. (2) Scree plot — plot eigenvalues in descending order; retain components before the "elbow" (the point where the curve flattens). Components after the elbow explain little additional variance. (3) Kaiser criterion — retain components with eigenvalue > 1 (i.e., each retained component explains more than one original standardised variable would). (4) Task-driven — if using PCA for visualisation, always use k=2 or k=3. If using as preprocessing for a classifier, tune k as a hyperparameter via cross-validation.
What is the key limitation of PCA and when should you use t-SNE or UMAP instead?+
PCA is a linear dimensionality reduction method — it can only capture linear relationships between features. If the intrinsic structure of the data lies on a non-linear manifold (e.g., the Swiss roll, or the structure of handwritten digit images), PCA projections will mix clusters together and lose the manifold topology.
t-SNE (t-distributed Stochastic Neighbour Embedding) is better for visualisation of high-dimensional data (like MNIST digits, single-cell RNA-seq, or Bengaluru startup ecosystem analysis): it preserves local neighbourhood structure beautifully, revealing tight clusters. Drawback: it's non-parametric (can't project new points), stochastic, slow O(n²), and the global distances between clusters are not meaningful. UMAP is faster and better preserves global structure while still revealing local clusters. Use PCA for preprocessing before ML (fast, deterministic, invertible). Use t-SNE/UMAP for exploration visualisation only.
PCA's mean recovery correlation on the mixed signals was 0.7280 — not close to zero. If PCA genuinely can't do source separation, why does it recover the sources partially rather than completely failing?+
PCA still finds the directions of maximum variance in the mixed data, and those directions are not completely unrelated to the original sources — the mixing matrix A is invertible and the sources do have different variances and some structure, so PCA's variance-maximising components end up partially aligned with the true sources purely by geometric coincidence, not because PCA understood anything about independence. That partial alignment is exactly what produces a middling 0.7280 correlation rather than either a perfect 1.0 or a true-zero result — PCA's components are simply the wrong basis for this problem, capturing some but not all of the structure ICA is specifically designed to find.
This is a useful general lesson: a mediocre result from the "wrong" method for a task is not the same as strong evidence the task is hard. ICA's 0.9991 on the identical input shows the sources were, in fact, cleanly recoverable — PCA's 0.7280 reflects a mismatch between method and problem, not a genuinely difficult signal.
⚠ Advanced Topic
Monte Carlo Simulation & Markov Chains
Two techniques for reasoning about randomness without a closed-form formula: simulate the process directly, many times, and read the answer off the results.
⚠ Why This Page Is Marked "Advanced"
Both techniques here trade a closed-form formula for repeated computation — instead of deriving an exact answer analytically, you write code that simulates the process thousands of times and reads the answer off the empirical results. This is a different way of thinking about probability than anything else in this course, and it underpins the Bayesian machinery (MCMC) covered on the next two pages.
Monte Carlo Simulation — The Core Idea
You already met one simulation-based technique on the Bootstrap Resampling page: resampling with replacement from your own observed data to see how a statistic varies. Monte Carlo simulation asks a related but distinct question — instead of resampling the data you have, you simulate fresh data from a fully-specified theoretical model (a known distribution with known parameters), repeat that many times, and use the resulting spread to answer questions a formula can't easily answer.
Bootstrap (already covered)
Monte Carlo Simulation (this page)
Resamples your actual observed data, with replacement
Simulates fresh data from a known, fully-specified distribution
Answers: "how would my statistic vary across different samples from whatever population my data came from?"
Answers: "if this theoretical model were exactly true, what would the statistic's distribution look like?"
Requires no assumption about the population's shape
Requires committing to a specific model (e.g. "assume the null hypothesis is exactly true")
Worked Example — Testing Normality via a Simulated Null Distribution
The Kolmogorov-Smirnov (KS) test checks whether a sample plausibly came from a reference distribution, using the maximum gap between the sample's empirical CDF and the reference CDF as its test statistic, D. Rather than using the KS test's known asymptotic formula for the p-value, we can build the null distribution of D ourselves by simulation — useful for tests or statistics where no such formula exists. This reuses the exact same 500-row Swiggy order-value dataset from the Bootstrap page:
import numpy as np
from scipy.stats import norm
# Same 500-row Swiggy order-value dataset as the Bootstrap page (same seed)
np.random.seed(9)
orders = np.concatenate([np.random.normal(350,80,480), np.random.normal(2200,400,20)])
n = len(orders)
# Step 1: estimate parameters, standardize the data
xbar, s = orders.mean(), orders.std(ddof=1)
standardized = (orders - xbar) / s
# Step 2: observed KS statistic D_obs vs. the standard normal
sorted_std = np.sort(standardized)
ecdf = np.arange(1, n+1) / n
D_obs = np.max(np.abs(ecdf - norm.cdf(sorted_std)))
print(f"D_obs = {D_obs:.4f}")
# Step 3: Monte Carlo simulate the null distribution — 1000 fresh N(0,1) samples
num_sims = 1000
D_sims = np.zeros(num_sims)
for i in range(num_sims):
sim = np.sort(np.random.normal(0, 1, n))
D_sims[i] = np.max(np.abs(ecdf - norm.cdf(sim)))
# Step 4: p-value = fraction of simulated D's at least as extreme as D_obs
p_value = np.mean(D_sims >= D_obs)
print(f"Monte Carlo p-value = {p_value:.4f}")
print(f"Null distribution D: mean={D_sims.mean():.4f}, range=[{D_sims.min():.4f}, {D_sims.max():.4f}]")
Output
D_obs = 0.3562
Monte Carlo p-value = 0.0000
Null distribution D: mean=0.0373, range=[0.0149, 0.0794]
Every one of the 1,000 simulated null-distribution D values tops out at 0.0794 — nowhere near the observed D_obs=0.3562. The Monte Carlo p-value of ≈0 correctly rejects the normality assumption, driven entirely by the 20 large bulk orders (mean ₹2,200) sitting in an otherwise ₹350-centred distribution. This is the same real dataset from the Bootstrap page — the fat right tail that widened the mean's bootstrap CI there is precisely what breaks the normality assumption here.
A Markov chain models a system that moves between a fixed set of states, where the probability of the next state depends only on the current state — not on the entire history that led there. This "memorylessness" is the Markov property:
P(X_{t+1}=j \mid X_t=i, X_{t-1},\dots,X_0) = P(X_{t+1}=j\mid X_t=i) \qquad \text{the transition matrix } P \text{ holds every } P(i\to j)
Each row of the transition matrix P must sum to 1 (from any given state, you go somewhere, with total probability 1). A state that transitions only to itself with probability 1 is called absorbing — once you enter it, you never leave.
Worked Example — A Support Ticket's Journey
Model a helpdesk ticket's lifecycle as a 5-state Markov chain: New → Assigned → InProgress ⇄ Escalated → Resolved, with Resolved absorbing. Simulating 30,000 tickets and tracking every path taken:
import numpy as np
from collections import Counter
states = ['New', 'Assigned', 'InProgress', 'Escalated', 'Resolved']
idx = {s: i for i, s in enumerate(states)}
# Rows sum to 1. Resolved (row 4) is absorbing.
P = np.array([
[0.0, 1.0, 0.0, 0.0, 0.0], # New -> Assigned
[0.0, 0.0, 0.85, 0.15, 0.0], # Assigned -> InProgress / Escalated
[0.0, 0.0, 0.25, 0.15, 0.60], # InProgress-> stays / Escalated / Resolved
[0.0, 0.0, 0.35, 0.15, 0.50], # Escalated -> InProgress / stays / Resolved
[0.0, 0.0, 0.0, 0.0, 1.0], # Resolved -> absorbing
])
def simulate_path():
current = idx['New']
path = ['New']
while current != idx['Resolved']:
nxt = np.random.choice(5, p=P[current])
path.append(states[nxt]); current = nxt
return tuple(path)
paths = [simulate_path() for _ in range(30000)]
counts = Counter(paths)
for path, count in counts.most_common(3):
print(f"{' -> '.join(path)} ({count/300:.1f}%)")
lengths = [len(p)-1for p in paths]
print(f"Average steps to resolution: {np.mean(lengths):.2f}")
print(f"Escalated at least once: {np.mean(['Escalated' in p for p in paths])*100:.1f}%")
Output
New -> Assigned -> InProgress -> Resolved (51.1%)
New -> Assigned -> InProgress -> InProgress -> Resolved (12.7%)
New -> Assigned -> Escalated -> Resolved (7.1%)
Average steps to resolution: 3.74
Escalated at least once: 31.8%
Just over half of all tickets resolve via the direct 3-step path with no detours, but almost a third get escalated at least once before resolution — a number that would be tedious to derive analytically from the transition matrix directly (it requires summing infinitely many possible looping paths), but falls straight out of simulation.
Try It — Simulate Tickets Through the Chain
This is the exact transition matrix from the code above. Click below to simulate one ticket at a time and watch it move through the chain, or run a batch of 1,000 to see the aggregate statistics converge toward the real 30,000-run numbers.
💡 When to Reach for Each Technique
Use Monte Carlo simulation when you need the distribution of a statistic under a specific, fully-specified model (a null hypothesis, a theoretical distribution) and no clean formula exists — or you'd rather not trust the formula's assumptions. Use a Markov chain whenever a system moves through a fixed set of states and the next-state probability depends only on the current state — funnels, lifecycle stages, queueing systems, and (as the next page shows) the sampling mechanism inside MCMC itself.
❓ Conceptual Q&A
Both the Bootstrap page and this page use the same 500-row order-value dataset, but reach different kinds of conclusions. What's fundamentally different about what each technique is doing with that data?+
The Bootstrap page resamples from the 500 real observations themselves — every bootstrap resample is built entirely out of values that actually appeared in the data, just possibly repeated or omitted. It never assumes anything about what distribution the data follows; it lets the data speak for itself about its own variability.
This page's Monte Carlo simulation does the opposite: it discards the actual order values (beyond using their mean and std to standardize) and instead generates 1,000 completely fresh samples from a hypothetical model — a standard normal distribution — that may or may not describe the real data well. The KS test's job is precisely to check whether that hypothetical model is a good description of reality, and the simulation-built null distribution is what makes the check possible without relying on the KS test's asymptotic formula.
The Monte Carlo p-value came out to ≈0.0000 — every one of the 1,000 simulated D values fell below 0.08, nowhere near the observed 0.3562. Does this mean the simulation was flawed?+
No — this is the simulation working correctly and delivering a genuinely useful (if unsurprising) answer. The 1,000 simulated samples were drawn from an actual standard normal distribution, so their D statistics measure how much a truly normal sample's empirical CDF naturally wobbles away from the theoretical CDF just by chance — and that wobble is small and consistent (0.015 to 0.079 across all 1,000 simulations).
The real order-value data's D_obs of 0.3562 is roughly 4-5× larger than anything the null distribution ever produced, which is exactly what you'd expect: the data is a mixture of mostly-₹350 orders plus 20 much larger ₹2,200 bulk orders, a shape a true normal distribution essentially never produces. A p-value of ≈0 is the simulation correctly reporting "this data's shape is not a chance fluctuation away from normal — it's a different shape entirely."
In the ticket Markov chain, why does the "average steps to resolution" (3.74) sit between the direct path's length (3) and the longer looping paths, rather than just being 3?+
The average is a weighted average across every path the simulation actually produced, not just the single most common one. While 51.1% of tickets take the direct 3-step path, the remaining ~49% loop through InProgress and/or Escalated one or more extra times before reaching Resolved — each extra loop adds steps, and a small fraction of tickets loop several times before finally resolving (the simulation capped exploration at 200 steps as a safety guard, though such long paths are exceedingly rare given the chain's structure).
Because the "stay" probabilities (InProgress→InProgress at 0.25, Escalated→Escalated at 0.15) are both well under 0.5, the chain has a strong pull toward Resolved at every step, which is exactly why the average (3.74) stays close to the direct path's length (3) rather than being pulled much higher by the longer paths — most of the probability mass really is concentrated on short paths, with a long thin tail of rarer, longer ones.