ML
Learn the fundamentals of Machine Learning, including data preprocessing, supervised and unsupervised learning, model evaluation, feature engineering, optimization, and real-world ML algorithms using Python and Scikit-learn.

#Machine Learning Fundamentals#
Friendly, beginner-to-advanced notes on the foundations of Machine Learning. Read it top to bottom the first time, then use the table of contents as a quick reference.
📑 Table of Contents
- Introduction to AI, ML and Deep Learning
- What Is Machine Learning?
- History and Evolution of ML
- Types of Machine Learning (Overview)
- Supervised Learning
- Unsupervised Learning
- Semi-Supervised Learning
- Self-Supervised Learning
- Reinforcement Learning
- Batch Learning vs Online Learning
- Instance-Based vs Model-Based Learning
- Applications and Limitations of ML
- Machine Learning Project Lifecycle
- Quick Revision Cheat Sheet
1. Introduction to Artificial Intelligence, Machine Learning and Deep Learning
These three terms get thrown around as if they mean the same thing. They don't. They are nested ideas, like Russian dolls.
┌──────────────────────────────────────────────┐
│ Artificial Intelligence (AI) │
│ ┌────────────────────────────────────────┐ │
│ │ Machine Learning (ML) │ │
│ │ ┌──────────────────────────────────┐ │ │
│ │ │ Deep Learning (DL) │ │ │
│ │ └──────────────────────────────────┘ │ │
│ └────────────────────────────────────────┘ │
└──────────────────────────────────────────────┘
🧠 Artificial Intelligence (AI)
The big goal: make machines do things that normally need human intelligence, such as reasoning, understanding language, recognising images, planning and making decisions.
AI is a goal, not a single technique. There are two broad ways to reach it:
| Approach | How it works | Example |
|---|---|---|
| Rule-based (symbolic) AI | Humans write explicit rules: "IF this THEN that" | A chess program with hand-coded strategies, an expert system for diagnosis |
| Learning-based AI | The machine figures out the rules from data | A spam filter that learns from millions of emails |
📊 Machine Learning (ML)
A subset of AI where systems learn patterns from data instead of being explicitly programmed with rules. You show the machine many examples, and it works out the pattern itself.
🕸️ Deep Learning (DL)
A subset of ML that uses artificial neural networks with many layers (hence "deep"). It is especially good at messy, unstructured data such as images, audio and text, because it can learn useful features on its own rather than needing a human to design them.
Comparing the three
| Aspect | AI | ML | Deep Learning |
|---|---|---|---|
| Scope | Broadest | Subset of AI | Subset of ML |
| Core idea | Intelligent behaviour | Learn from data | Learn with many-layered neural networks |
| Needs data? | Not always | Yes | Usually a lot |
| Feature design | Hand-crafted rules | Often hand-crafted features | Learns features automatically |
| Compute needs | Varies | Moderate | High (often GPUs) |
| Example | Rule-based chatbot | Spam filter | Face recognition, speech-to-text |
💡 Remember it like this: All deep learning is machine learning, and all machine learning is AI, but not all AI is machine learning.
2. What Is Machine Learning?
The simple definition
Machine Learning is the practice of building systems that improve at a task through experience (data), without being told step by step how to do it.
Two classic definitions worth remembering
- Arthur Samuel (1959): ML is the field of study that gives computers the ability to learn without being explicitly programmed.
- Tom Mitchell (1997), the more precise one: A program is said to learn from experience E with respect to some task T and performance measure P, if its performance at T, as measured by P, improves with experience E.
Example (spam filter):
- T (Task): flag spam emails
- E (Experience): a set of emails labelled "spam" or "not spam"
- P (Performance): percentage of emails classified correctly (accuracy)
Traditional programming vs Machine Learning
TRADITIONAL PROGRAMMING
Data ─┐
├──► Program ──► Output
Rules ─┘
MACHINE LEARNING
Data ─┐
├──► Learning Algorithm ──► Rules (the "model")
Output ─┘
In traditional programming, you write the rules. In ML, you give data and the answers, and the algorithm discovers the rules.
Why do we even need ML?
- Rules are too complex to write by hand. Try writing exact rules to recognise a cat in any photo.
- Rules keep changing. Spammers change tactics constantly; a learning system can adapt by retraining.
- Problems are too large or messy for humans to hand-code (speech, language, huge datasets).
- Insights from data. ML can reveal patterns humans would miss, helping us understand a problem better.
Core vocabulary you'll see everywhere
| Term | Meaning |
|---|---|
| Dataset | The collection of examples used for learning |
| Instance / Sample / Example | One row in the dataset (e.g., one email, one house) |
| Feature (attribute) | An input property of an instance (e.g., size, location, word count) |
| Label / Target | The answer we want to predict (e.g., price, spam/not spam) |
| Model | The learned mathematical function that maps inputs to outputs |
| Training | The process of the algorithm learning from data |
| Training set | Data used to teach the model |
| Test set | Unseen data used to check how well the model learned |
| Prediction / Inference | Using the trained model on new data |
| Parameters | Values the model learns by itself during training |
| Hyperparameters | Settings you choose before training (e.g., learning rate) |
| Generalisation | How well the model performs on new, unseen data |
A tiny sample dataset to picture it
| Size (sq ft) | Bedrooms | Location score | Price (label) |
|---|---|---|---|
| 1200 | 2 | 7 | 180,000 |
| 1800 | 3 | 8 | 290,000 |
| 950 | 1 | 5 | 120,000 |
Each row is an instance, the first three columns are features, and Price is the label.
3. History and Evolution of ML
ML has had a rollercoaster journey, with periods of excitement followed by disappointment ("AI winters"), and finally the current boom.
Timeline
| Era | Milestone |
|---|---|
| 1943 | McCulloch & Pitts propose a mathematical model of an artificial neuron |
| 1950 | Alan Turing publishes "Computing Machinery and Intelligence" and proposes the Turing Test |
| 1952 | Arthur Samuel builds a checkers program that improves by playing |
| 1956 | The Dartmouth Conference coins the term "Artificial Intelligence" |
| 1958 | Frank Rosenblatt invents the Perceptron, an early neural network |
| 1960s | Early pattern recognition and nearest-neighbour ideas appear |
| 1969 | Minsky & Papert show the limits of single-layer perceptrons, and neural network enthusiasm drops |
| 1974–1980 | First AI Winter: funding and interest decline |
| 1980s | Expert systems boom; backpropagation is popularised (1986), reviving neural networks |
| 1987–1993 | Second AI Winter: expert systems prove brittle and expensive |
| 1990s | Shift to data-driven ML: Support Vector Machines, decision trees, ensemble ideas, and practical applications such as spam filtering |
| 1997 | IBM's Deep Blue defeats chess champion Garry Kasparov |
| 2006 | Geoffrey Hinton and colleagues revive interest in deep learning |
| 2011 | IBM Watson wins Jeopardy! |
| 2012 | AlexNet wins the ImageNet competition by a large margin, triggering the deep learning boom |
| 2014 | Generative Adversarial Networks (GANs) are introduced |
| 2016 | DeepMind's AlphaGo defeats world Go champion Lee Sedol |
| 2017 | The Transformer architecture is introduced ("Attention Is All You Need") |
| 2018 onwards | Large pre-trained language models and self-supervised learning take off |
| 2020s | Generative AI and large foundation models reach the mainstream |
Why did ML take off in the 2010s?
Three ingredients finally came together:
- 📦 Big Data: the internet, smartphones and sensors produce massive datasets.
- ⚡ Compute power: GPUs and cloud computing make heavy training affordable.
- 🧪 Better algorithms and open tools: improved techniques and free libraries lowered the barrier to entry.
The big shifts in thinking
Rule-based systems ──► Statistical / classical ML ──► Deep Learning ──► Large pre-trained (foundation) models
(hand-written rules) (learn from hand-made features) (learn features too) (learn general knowledge, then adapt)
4. Types of Machine Learning
ML systems can be categorised in several different ways. These categories are not mutually exclusive: a single system can fit into more than one.
Way 1: By the amount and type of supervision during training
| Type | Short idea |
|---|---|
| Supervised | Learn from labelled data |
| Unsupervised | Find structure in unlabelled data |
| Semi-supervised | A little labelled data + lots of unlabelled data |
| Self-supervised | The data creates its own labels |
| Reinforcement | Learn by trial, error and rewards |
Way 2: By whether the system learns incrementally
- Batch learning vs Online learning (see Section 10)
Way 3: By how the system generalises
- Instance-based vs Model-based learning (see Section 11)
🧩 Example of combining categories: A modern spam filter can be supervised (trained on labelled emails), online (updates continuously), and model-based (learns a model rather than comparing to stored emails).
5. Supervised Learning
🎯 Core idea
The training data comes with the correct answers (labels). Like a student studying with an answer key, the model learns the relationship between inputs (features) and outputs (labels), then predicts labels for new data.
Features (X) + Labels (y) ──► Training ──► Model ──► Predictions for new X
The two main kinds of supervised tasks
A) Classification: predict a category
The output is a discrete class.
- Binary classification: two classes (spam / not spam, fraud / genuine, disease / no disease)
- Multi-class classification: more than two classes (digit 0–9, type of flower)
- Multi-label classification: one item can have several labels at once (a photo tagged beach, sunset and people)
B) Regression: predict a number
The output is a continuous value.
- Predicting house prices
- Forecasting temperature
- Estimating a person's age from a photo
| Classification | Regression | |
|---|---|---|
| Output | Category / class | Continuous number |
| Question answered | "Which one?" | "How much?" |
| Example | Is this email spam? | What will this house sell for? |
Common supervised algorithms (just names, so you can recognise them)
Linear Regression, Logistic Regression, k-Nearest Neighbours, Decision Trees, Random Forests, Support Vector Machines, Naive Bayes, Neural Networks.
Strengths and weaknesses
| ✅ Strengths | ⚠️ Weaknesses |
|---|---|
| Clear, measurable goals | Needs labelled data, which can be costly and slow to get |
| Easy to evaluate against known answers | Quality of labels limits quality of the model |
| Very mature and widely used | Can't discover patterns it was never shown labels for |
Real-world examples
Email spam detection, credit-card fraud detection, medical image diagnosis, loan default prediction, price prediction, handwriting recognition.
6. Unsupervised Learning
🔍 Core idea
The training data has no labels. The algorithm explores the data and tries to discover hidden structure by itself, like sorting a pile of unknown objects into groups without anyone telling you what the groups are.
Unlabelled data (X only) ──► Algorithm ──► Discovered structure (groups, patterns, simplified view)
Main kinds of unsupervised tasks
| Task | What it does | Example |
|---|---|---|
| Clustering | Groups similar instances together | Segmenting customers by behaviour |
| Dimensionality reduction | Shrinks the number of features while keeping the important information | Compressing 100 features into 2–3 for visualisation |
| Anomaly / outlier detection | Spots instances that look very different from normal | Detecting unusual bank transactions or machine faults |
| Novelty detection | Spots new instances unlike anything in clean training data | Flagging a new kind of defect |
| Association rule learning | Finds relationships between items | "People who buy bread often buy butter" (market-basket analysis) |
Common unsupervised algorithms (names only)
K-Means, Hierarchical Clustering, DBSCAN, Principal Component Analysis (PCA), t-SNE, Autoencoders, Isolation Forest, Apriori.
Strengths and weaknesses
| ✅ Strengths | ⚠️ Weaknesses |
|---|---|
| No labelling cost | Hard to know if the results are "correct" |
| Can reveal unexpected patterns | Results can be subjective and need human interpretation |
| Great for exploring unfamiliar data | Output quality depends heavily on the algorithm and settings |
Real-world examples
Customer segmentation, recommendation groupings, fraud and anomaly detection, data compression, visualising high-dimensional data, topic grouping of documents.
7. Semi-Supervised Learning
🧪 Core idea
Labelling data is expensive, but unlabelled data is usually cheap and plentiful. Semi-supervised learning uses a small amount of labelled data together with a large amount of unlabelled data.
Few labelled examples ┐
├──► Model learns from both ──► Better than labelled data alone
Many unlabelled ones ┘
An intuitive example
Your photo app groups all photos of the same person (an unsupervised step, by clustering faces). You then label that person once ("This is Maya"), and the app automatically tags her in all the other photos (a supervised step).
How it typically works
- Train an initial model on the small labelled set.
- Use it to predict labels for the unlabelled data (these are called pseudo-labels).
- Keep the confident predictions and add them to the training set.
- Retrain and repeat.
Why it's useful
- Cuts labelling effort and cost dramatically
- Often performs better than using only the few labels you have
- Practical in fields where expert labelling is expensive (medical scans, speech, legal documents)
Watch out
- If the initial model makes confident mistakes, those errors can be reinforced through pseudo-labels.
- It works best when the unlabelled data comes from the same kind of distribution as the labelled data.
Real-world examples
Medical image analysis, speech recognition, web page classification, photo-tagging services.
8. Self-Supervised Learning
🪞 Core idea
The system creates its own labels from the data itself, so no humans need to label anything. It hides or removes part of the input and trains the model to predict the missing part from the rest.
This is sometimes seen as a clever form of unsupervised learning, because the raw data is unlabelled, but the training itself looks like supervised learning (there's a target to predict, generated automatically).
Raw data ──► Hide a part ──► Model tries to predict the hidden part ──► Compare with the real part ──► Learn
Simple examples of "made-up" tasks (called pretext tasks)
| Data type | Pretext task |
|---|---|
| Text | Hide a word in a sentence and predict it: "The cat sat on the ___" |
| Text | Predict the next word in a sequence |
| Images | Mask a patch of an image and reconstruct it |
| Images | Predict the rotation applied to a picture |
| Audio / Video | Predict missing or future segments |
Why it matters
- Huge amounts of raw, unlabelled data (text, images, audio) are available for free.
- The model learns rich general representations of the world.
- These pre-trained models can then be fine-tuned on a small labelled dataset for a specific job. This is the recipe behind many modern large language and vision models.
Pre-training then fine-tuning
Step 1 (Self-supervised pre-training) Step 2 (Supervised fine-tuning)
Massive unlabelled data ──► General model ──► Small labelled data ──► Specialised model
Comparing with its neighbours
| Supervised | Unsupervised | Self-supervised | |
|---|---|---|---|
| Needs human labels? | Yes | No | No |
| Where do labels come from? | Humans | None | The data itself |
| Typical goal | Predict given labels | Find structure | Learn useful representations |
9. Reinforcement Learning
🎮 Core idea
An agent learns by interacting with an environment, taking actions and receiving rewards (good) or penalties (bad). Over time it learns a policy, a strategy that maximises the total reward. It's like training a dog with treats, or learning a video game by playing it again and again.
action
┌────────────────────┐
│ ▼
┌───────┐ ┌─────────────┐
│ Agent │◄────────│ Environment │
└───────┘ state + └─────────────┘
reward
Key terms
| Term | Meaning |
|---|---|
| Agent | The learner or decision-maker |
| Environment | The world the agent interacts with |
| State | The current situation the agent observes |
| Action | A move the agent can make |
| Reward | Feedback signal after an action (positive or negative) |
| Policy | The agent's strategy: which action to take in which state |
| Value | Expected total future reward from a state |
| Episode | One complete run from start to end of a task |
The learning loop
- Agent observes the current state.
- It picks an action using its policy.
- The environment gives back a reward and a new state.
- The agent updates its policy to get more reward in future.
- Repeat many, many times.
The big challenge: Exploration vs Exploitation
- Exploration: try new actions to discover potentially better strategies.
- Exploitation: use what you already know works to collect reward now.
A good agent balances both. Think of choosing a restaurant: do you go to your favourite (exploit) or try the new place (explore)?
What makes it different from supervised learning?
- There are no labelled correct answers, only reward signals.
- Rewards are often delayed. A move made now might only pay off many steps later.
- The agent's own actions change what data it sees next.
Real-world examples
Game-playing agents (chess, Go, video games), robot movement and control, self-driving decision-making, resource allocation, recommendation tuning, and fine-tuning language models using human feedback.
Strengths and weaknesses
| ✅ Strengths | ⚠️ Weaknesses |
|---|---|
| Learns complex behaviour without labelled data | Needs a huge number of trial interactions |
| Can discover strategies humans haven't thought of | Designing a good reward is hard and a poorly designed one gets exploited |
| Suited to sequential decision-making | Training can be unstable and expensive |
10. Batch Learning vs Online Learning
This category is about how and when the system learns: all at once, or little by little.
📦 Batch Learning (Offline Learning)
The model is trained using all the available data in one go. Once trained, it is deployed and stops learning. It just applies what it knows.
- To learn from new data, you must retrain from scratch on the old + new data, then replace the old model.
- This retraining can be automated and scheduled (daily, weekly, etc.).
Pros
- Simple to build and understand
- Stable and predictable behaviour
- Easier to test before deployment
Cons
- Needs lots of compute, time and memory for each full retraining
- Can't adapt quickly to fast-changing situations
- Struggles with truly huge datasets that don't fit in memory
- Performance can slowly decay as the real world changes (called model drift)
🌊 Online Learning (Incremental Learning)
The model is trained incrementally by feeding it data instances one by one or in small groups (mini-batches). It learns on the fly, and each learning step is fast and cheap.
Pros
- Adapts rapidly to new data and changing conditions
- Uses little memory because old data can be discarded after learning
- Handles huge datasets via out-of-core learning (loading chunks of data piece by piece)
- Suited to continuous data streams (stock prices, sensor data)
Cons
- Bad data can quickly harm the model (e.g., a faulty sensor or malicious inputs), so live monitoring is needed
- Harder to manage and debug
- Needs careful tuning of the learning rate
The learning rate in online learning
- High learning rate: adapts fast but quickly forgets older patterns.
- Low learning rate: learns slowly and is more stable but less responsive to new trends and more resistant to noise.
Side-by-side comparison
| Feature | Batch Learning | Online Learning |
|---|---|---|
| Training data | Entire dataset at once | Streams, one at a time or small groups |
| Updates after deployment | Retrain from scratch | Continuous small updates |
| Adapts to change | Slowly | Quickly |
| Resource usage | High per retrain | Low per update |
| Handles huge data | Difficult | Good (out-of-core) |
| Risk | Model goes stale | Bad data can degrade it quickly |
| Typical use | Image classifier retrained monthly | Live stock or recommendation updates |
🔔 Note: The word "online" here refers to learning incrementally, not to the internet. An online learner doesn't have to be connected to a network.
11. Instance-Based vs Model-Based Learning
This category is about how the system generalises, meaning how it makes predictions on data it has never seen.
🗃️ Instance-Based Learning
The system memorises the training examples and, for a new case, compares it to the stored ones using a similarity measure. It generalises by saying "this new item looks most like these known ones."
Example: A spam filter that flags a new email if it's very similar (shares many words) to emails already known to be spam. The classic algorithm is k-Nearest Neighbours (k-NN).
New point ──► Find the most similar stored examples ──► Predict based on those neighbours
Pros
- Simple and intuitive
- No explicit training phase (it "learns" by storing data)
- Can handle complex, irregular patterns
Cons
- Needs to store all the data, so memory-heavy
- Predictions can be slow since it compares against many stored examples
- Sensitive to irrelevant features and the choice of similarity measure
📐 Model-Based Learning
The system builds a model from the training examples and then uses the model to make predictions. It generalises by learning a general rule or function that summarises the data.
Example: Fit a line that relates house size to price, then use that line to predict prices for new houses (Linear Regression).
Training data ──► Choose a model type ──► Learn its parameters ──► Use the model to predict
The typical workflow of model-based learning
- Study the data and pick a model type (e.g., a linear model).
- Define a performance measure: either a utility/fitness function (how good the model is) or a cost/loss function (how bad the model is).
- Train the model by finding the parameter values that minimise the cost.
- Apply the model to make predictions on new data (inference).
A simple model might look like: price = θ₀ + θ₁ × size. Training finds the best values of θ₀ and θ₁.
Pros
- Fast predictions once trained (just apply the function)
- The training data doesn't need to be stored afterwards
- Often more compact and scalable
Cons
- Needs a training phase
- If the chosen model type is a poor fit for the data, results suffer
Comparison
| Feature | Instance-Based | Model-Based |
|---|---|---|
| How it generalises | Compares new data to stored examples | Uses a learned function/model |
| What's stored | All (or many) training instances | Only the model parameters |
| Training phase | Minimal (just storing) | Explicit optimisation |
| Prediction speed | Slower with large data | Fast |
| Memory | High | Low |
| Example | k-Nearest Neighbours | Linear Regression, Neural Networks |
12. Applications and Limitations of ML
🌍 Applications
Healthcare
- Detecting diseases from X-rays, MRIs and scans
- Predicting patient risk and treatment outcomes
- Speeding up drug discovery
Finance
- Fraud detection
- Credit scoring and loan approval
- Algorithmic trading and risk assessment
E-commerce and Entertainment
- Product and movie/music recommendations
- Dynamic pricing
- Customer segmentation and churn prediction
Transportation
- Self-driving and driver-assistance systems
- Route optimisation and traffic prediction
- Demand forecasting for ride services
Language and Speech
- Machine translation, chatbots and virtual assistants
- Speech-to-text and voice assistants
- Sentiment analysis and text summarisation
Computer Vision
- Face and object recognition
- Quality inspection in manufacturing
- Document and handwriting recognition (OCR)
Industry and Agriculture
- Predictive maintenance for machines
- Crop disease detection and yield prediction
Cybersecurity
- Intrusion and malware detection
- Spam and phishing filters
🚧 Limitations and Challenges
ML is powerful but not magic. Knowing its limits is just as important as knowing its strengths.
1. Data problems
| Issue | What it means |
|---|---|
| Insufficient data | Models often need lots of examples to learn well; too few gives poor results |
| Non-representative data | If training data doesn't reflect the real world, the model won't generalise (sampling bias) |
| Poor quality data | Errors, noise, outliers and missing values lead to poor models |
| Irrelevant features | Useless inputs confuse the model; good features matter a lot |
| Label errors | In supervised learning, wrong labels teach wrong lessons |
🗑️ "Garbage in, garbage out." A model is only as good as its data.
2. Model problems
- Overfitting: the model memorises the training data (including its noise) and performs poorly on new data. Like a student who memorised past answers but can't solve new questions.
- Underfitting: the model is too simple to capture the pattern and performs poorly even on training data.
- Poor generalisation: works in the lab, fails in the real world.
Underfitting Good fit Overfitting
(too simple) (just right) (too complex)
Misses the Captures the Chases every
pattern real pattern bit of noise
Ways to fight overfitting: get more data, simplify the model, add regularisation (constraining the model), and reduce noise in the data. Ways to fight underfitting: use a more powerful model, provide better features, reduce constraints on the model.
3. Ethical and social concerns
- Bias and fairness: models can inherit and even amplify biases present in data, leading to unfair outcomes.
- Privacy: training often involves personal or sensitive data.
- Accountability: who is responsible when an automated decision goes wrong?
- Misuse: the same technology can be used harmfully (e.g., deceptive fake content).
4. Practical and technical limits
- Interpretability ("black box" problem): complex models, especially deep ones, are hard to explain.
- Cost: collecting data, labelling it, and computing power can be expensive.
- Correlation, not causation: ML finds patterns, which doesn't mean it understands why they exist.
- Changing environments: models degrade when the real world drifts away from the training data.
- Brittleness: small, unusual changes to inputs can sometimes cause large errors.
- Not always the right tool: if a simple rule solves the problem, ML may be unnecessary overkill.
When should you use ML?
| ✅ ML is a good fit when | ❌ ML is probably not needed when |
|---|---|
| Rules are too complex to hand-code | A simple, fixed rule works fine |
| The problem changes over time | The logic is stable and clearly known |
| You have enough relevant data | You have little or no data |
| Approximate answers are acceptable | You need guaranteed 100% exact results |
13. Machine Learning Project Lifecycle
A real ML project is much more than "train a model." It's an iterative cycle, and you will often loop back to earlier steps.
┌─► 1. Define the Problem
│ │
│ 2. Collect Data
│ │
│ 3. Prepare & Explore Data
│ │
│ 4. Feature Engineering
│ │
│ 5. Select & Train Model(s)
│ │
│ 6. Evaluate
│ │
│ 7. Tune & Improve ───────┐ (loop back if not good enough)
│ │ │
│ 8. Deploy │
│ │ │
└── 9. Monitor & Maintain ◄───┘
Step 1: Define the Problem and Goals
Before touching data, get clarity.
- What's the business or real-world objective?
- What does success look like, and how will it be measured?
- Is ML even the right approach? Which type of ML fits (classification, regression, clustering...)?
- What are the constraints (time, cost, accuracy needed, interpretability)?
- How is the problem solved today? (This gives you a baseline to beat.)
Step 2: Data Collection
- Identify and gather data from relevant sources (databases, files, APIs, sensors, surveys).
- Make sure there's enough data and that it is relevant and representative.
- Consider legal and ethical issues such as privacy and consent.
Step 3: Data Preparation and Exploration (EDA)
Usually the most time-consuming step.
Exploration (Exploratory Data Analysis): understand your data via summaries, charts, distributions and correlations.
Cleaning and preprocessing:
- Handle missing values (remove, or fill in sensibly)
- Remove or treat outliers and duplicates
- Fix inconsistent formats and errors
- Encode categorical variables into numbers
- Scale/normalise numerical features where needed
Splitting the data: the golden rule is to never evaluate on data you trained on.
| Split | Purpose | Typical share |
|---|---|---|
| Training set | Teach the model | ~60–80% |
| Validation set | Compare models and tune settings | ~10–20% |
| Test set | Final, honest check of performance (touch it only once at the end) | ~10–20% |
⚠️ Data leakage: accidentally letting information from the test set (or from the future) slip into training. It gives unrealistically great results that collapse in the real world.
Step 4: Feature Engineering
Features often matter more than which algorithm you choose.
- Feature selection: choose the most useful existing features.
- Feature extraction: combine features into more informative ones.
- Feature creation: build new features from domain knowledge (e.g., from a date, derive "day of week" or "is weekend").
Step 5: Model Selection and Training
- Start with a simple baseline model so you have something to compare against.
- Try several candidate models suited to your problem type.
- Train each on the training data.
Step 6: Evaluation
Measure how well the model does using suitable metrics on validation/test data.
| Task | Common metrics |
|---|---|
| Classification | Accuracy, Precision, Recall, F1-score, Confusion Matrix, ROC-AUC |
| Regression | MAE (Mean Absolute Error), MSE / RMSE, R² score |
| Clustering | Silhouette score (and human judgement) |
Key classification terms:
- Precision: of everything predicted positive, how much was truly positive?
- Recall: of everything truly positive, how much did we catch?
- F1-score: balance of precision and recall.
Also check overfitting vs underfitting by comparing training and validation performance. A large gap suggests overfitting.
Step 7: Tuning and Improvement
- Hyperparameter tuning (e.g., via grid search or random search)
- Better features, more or cleaner data
- Trying different or combined models
- Cross-validation for more reliable performance estimates
- Iterate: go back to earlier steps as needed
Step 8: Deployment
Put the model into the real world where it creates value.
- Integrate into an app, website, service or pipeline (e.g., exposed through an API)
- Decide on batch predictions (processed periodically) vs real-time predictions (instant)
- Ensure it runs reliably and at the required speed and scale
Step 9: Monitoring and Maintenance
Deployment is not the finish line.
- Track performance continuously in production.
- Watch for data drift (input data changes) and concept drift (the relationship between input and output changes).
- Retrain or update the model regularly with fresh data.
- Keep versions, logs and documentation so results are reproducible.
- Handle feedback, errors and edge cases.
Where does your learning type come in?
Your choice from earlier sections shapes the lifecycle: for example, batch systems schedule periodic retraining, while online systems build live monitoring and safeguards into the pipeline.
14. Quick Revision Cheat Sheet
The big picture
AI ⊃ ML ⊃ Deep Learning
Types of ML at a glance
| Type | Data | Goal | Example |
|---|---|---|---|
| Supervised | Labelled | Predict labels | Spam detection, house prices |
| Unsupervised | Unlabelled | Find hidden structure | Customer clustering |
| Semi-supervised | Few labelled + many unlabelled | Learn with less labelling | Photo tagging |
| Self-supervised | Unlabelled (self-generated labels) | Learn representations | Predicting masked words |
| Reinforcement | Interaction + rewards | Maximise cumulative reward | Game-playing agents |
Other ways to categorise
| Dimension | Option A | Option B |
|---|---|---|
| When it learns | Batch (all at once, offline) | Online (incrementally, continuously) |
| How it generalises | Instance-based (compare to stored examples) | Model-based (learn a function) |
Common problems
| Problem | Fix |
|---|---|
| Not enough data | Collect more, use semi/self-supervised approaches |
| Bad quality data | Clean, preprocess, fix labels |
| Overfitting | More data, simpler model, regularisation |
| Underfitting | More powerful model, better features |
| Model goes stale | Monitor and retrain |
ML lifecycle in one line
Define → Collect → Prepare → Engineer Features → Train → Evaluate → Tune → Deploy → Monitor (and loop!)
✅ Final Takeaways
- ML = learning patterns from data instead of hand-writing rules.
- Choose the learning type based on the data and labels you have.
- Data quality matters more than almost anything else.
- Always evaluate on unseen data to check true generalisation.
- ML is an ongoing cycle, not a one-time build. Deploy, monitor, improve, repeat.
#Mathematics for Machine Learning#
Friendly, beginner-to-advanced notes on the math that powers Machine Learning. Every concept comes with a plain-English idea, a small worked example, and a note on why ML cares.
📑 Table of Contents
- Linear Algebra
- Calculus
- Probability
- Statistics
- Optimization
- Information Theory
- Quick Revision Cheat Sheet
🗺️ How the pieces fit together
Linear Algebra ──► how data and models are REPRESENTED (vectors, matrices)
Calculus ──► how models CHANGE when we tweak their parameters (gradients)
Probability ──► how we handle UNCERTAINTY (distributions, Bayes)
Statistics ──► how we LEARN FROM DATA and judge results (estimates, tests)
Optimization ──► how we FIND the best parameters (gradient descent)
Information Th.──► how we MEASURE uncertainty and difference (entropy, KL, loss)
1. Linear Algebra
The big idea: Linear algebra is the language of data. A dataset is a matrix, an image is a grid of numbers, a model's weights are vectors and matrices. Almost every ML computation is a linear algebra operation under the hood.
1.1 Scalars, Vectors, Matrices, Tensors
These are the four "containers" for numbers, differing only in how many dimensions they have.
| Object | Dimensions | Notation (usual) | Example | ML example |
|---|---|---|---|---|
| Scalar | 0-D (a single number) | x (lowercase italic) | 7 | A learning rate, a bias value |
| Vector | 1-D (a list of numbers) | v (bold lowercase) | [2, 5, 1] | One data sample with 3 features |
| Matrix | 2-D (a table of numbers) | A (bold uppercase) | [[1,2],[3,4]] | A dataset: rows = samples, columns = features |
| Tensor | n-D (a general array) | T | A 3-D grid | A colour image (height × width × 3 channels), a batch of images (4-D) |
Scalar Vector Matrix Tensor (3-D)
5 [1] [1 2 3] a stack of matrices,
[4] [4 5 6] like a cube of numbers
[2]
Shape tells you the size of each dimension. A matrix with 100 rows and 5 columns has shape (100, 5).
💡 Think of it as a ladder: scalar → vector (list of scalars) → matrix (list of vectors) → tensor (list of matrices).
1.2 Matrix Operations
Addition and subtraction
Done element by element. Both matrices must have the same shape.
[1 2] [5 6] [ 6 8]
[3 4] + [7 8] = [10 12]
Scalar multiplication
Multiply every element by the number.
[1 2] [3 6]
3×[3 4] = [9 12]
Matrix multiplication
This is not element by element. The entry in row i, column j of the result is the dot product of row i of A with column j of B.
A = [1 2] B = [5 6]
[3 4] [7 8]
A×B = [1·5+2·7 1·6+2·8] [19 22]
[3·5+4·7 3·6+4·8] = [43 50]
Rules to remember
- Shape rule:
(m × n) · (n × p) = (m × p). The inner dimensions must match. - Not commutative: in general,
A·B ≠ B·A. - Associative:
(A·B)·C = A·(B·C). - Distributive:
A·(B + C) = A·B + A·C.
Element-wise (Hadamard) product
Multiply matching positions only (written A ⊙ B). Shapes must be the same. This shows up constantly in neural networks.
🧠 Why ML cares
A whole layer of a neural network is essentially output = W·x + b, a matrix multiplication plus a bias. Predicting for 1,000 samples at once is one big matrix multiplication.
1.3 Transpose
Flip a matrix over its diagonal: rows become columns and columns become rows. Written Aᵀ.
A = [1 2 3] Aᵀ = [1 4]
[4 5 6] [2 5]
[3 6]
(2×3) becomes (3×2).
Properties
(Aᵀ)ᵀ = A(A + B)ᵀ = Aᵀ + Bᵀ(A·B)ᵀ = Bᵀ·Aᵀ(note the order reverses!)- A matrix is symmetric if
A = Aᵀ. Covariance matrices are always symmetric.
1.4 Identity Matrix and Inverse
Identity matrix (I)
The matrix equivalent of the number 1. It has 1s on the diagonal and 0s elsewhere. Multiplying by it changes nothing: A·I = I·A = A.
I₃ = [1 0 0]
[0 1 0]
[0 0 1]
Inverse matrix (A⁻¹)
The matrix equivalent of "1 divided by a number". It undoes what A does:
A · A⁻¹ = A⁻¹ · A = I
Key facts
- Only square matrices can have an inverse, and not all of them do.
- A matrix with an inverse is called invertible (or non-singular). One without is singular.
- A matrix is invertible if and only if its determinant ≠ 0.
(A·B)⁻¹ = B⁻¹·A⁻¹
2×2 shortcut: For A = [[a, b], [c, d]]:
A⁻¹ = 1/(ad − bc) × [ d −b]
[−c a]
Example: A = [[4, 2], [1, 3]] has ad − bc = 12 − 2 = 10, so
A⁻¹ = (1/10) × [ 3 −2] = [ 0.3 −0.2]
[−1 4] [−0.1 0.4]
🧠 Why ML cares
Solving linear systems like A·x = b is x = A⁻¹·b. Linear Regression's closed-form solution (the "normal equation") uses an inverse. In practice, computers use more stable numerical methods rather than computing the inverse directly.
1.5 Determinant
A single number computed from a square matrix that tells you how the matrix scales space.
2×2: det [[a, b], [c, d]] = ad − bc
3×3 (expansion along the first row):
det(A) = a(ei − fh) − b(di − fg) + c(dh − eg) for A = [[a,b,c],[d,e,f],[g,h,i]]
What it means
- |det| = the factor by which areas (2-D) or volumes (3-D) get scaled by the matrix.
- Sign: a negative determinant means the transformation flips orientation (like a mirror).
- det = 0 means the matrix squashes space into a lower dimension (a plane into a line). It loses information, so it is not invertible.
Properties
det(A·B) = det(A) · det(B)det(Aᵀ) = det(A)det(A⁻¹) = 1 / det(A)det(I) = 1- The determinant equals the product of the eigenvalues.
1.6 Rank
Rank = the number of linearly independent rows (or columns) in a matrix. In other words, how much genuinely distinct information the matrix holds.
[1 2 3]
[2 4 6] ← this row is just 2 × row 1 (redundant!)
[1 0 1]
Rank = 2 (only two independent rows)
Key facts
- Rank ≤ min(rows, columns).
- Full rank means rank equals the smaller dimension (no redundancy).
- A square
n×nmatrix is invertible only if its rank is n. - Rank-deficient matrices have redundant (dependent) information.
🧠 Why ML cares
If two features are perfectly redundant (one is a multiple or combination of others), the feature matrix is rank-deficient, which can break some algorithms. Rank also tells us the "true dimensionality" of data.
1.7 Dot Product
For two vectors of the same length, multiply matching entries and add them up. The result is a scalar.
a = [1, 2, 3] b = [4, 5, 6]
a · b = 1×4 + 2×5 + 3×6 = 4 + 10 + 18 = 32
Geometric meaning
a · b = ‖a‖ · ‖b‖ · cos(θ)
where ‖a‖ is the length (norm) of a, ‖a‖ = √(a₁² + a₂² + …), and θ is the angle between the vectors.
| Dot product value | What it says |
|---|---|
| Positive | Vectors point in a similar direction (angle < 90°) |
| Zero | Vectors are orthogonal (perpendicular, 90°) |
| Negative | Vectors point in opposite-ish directions (angle > 90°) |
Useful by-products
- Cosine similarity =
(a · b) / (‖a‖ ‖b‖), ranging from −1 to 1. It measures how aligned two vectors are regardless of their length. - Vector length:
‖a‖ = √(a · a) - Projection of a onto b =
((a · b) / (b · b)) × b, the "shadow" of a on b.
🧠 Why ML cares
A neuron computes w · x + b, a dot product between weights and inputs. Similarity between documents, embeddings, and recommendations is often a dot product or cosine similarity.
1.8 Vector Spaces
A vector space is a collection of vectors where you can add any two vectors and scale any vector by a number, and the result stays inside the collection. (Think of the 2-D plane ℝ² or 3-D space ℝ³.)
Core building blocks
| Concept | Meaning | Example |
|---|---|---|
| Linear combination | A sum of scaled vectors: c₁v₁ + c₂v₂ + … | 2·[1,0] + 3·[0,1] = [2,3] |
| Span | All vectors you can build from linear combinations of a set | The span of [1,0] and [0,1] is the whole plane |
| Linear independence | No vector in the set can be built from the others | [1,0] and [0,1] are independent; [1,2] and [2,4] are not |
| Basis | A set of linearly independent vectors that spans the space | [1,0] and [0,1] (the standard basis) |
| Dimension | The number of vectors in a basis | ℝ² has dimension 2 |
| Subspace | A smaller vector space living inside a bigger one | A line through the origin inside 3-D space |
Related ideas
- Column space of a matrix = the span of its columns. Its dimension equals the rank.
- Null space = all vectors x such that
A·x = 0. - Orthogonal vectors have zero dot product. An orthonormal basis has vectors that are perpendicular and have length 1, which makes computations clean.
🧠 Why ML cares
Features live in a vector space. Techniques like dimensionality reduction find a smaller subspace that captures most of the information in the data.
1.9 Eigenvalues and Eigenvectors
A matrix usually rotates and stretches vectors. But some special vectors only get stretched or shrunk and keep their direction. These are eigenvectors, and the stretch factor is the eigenvalue.
A · v = λ · v
- v = eigenvector (non-zero)
- λ (lambda) = eigenvalue
How to find them (for a square matrix)
- Solve the characteristic equation:
det(A − λI) = 0to find the eigenvalues λ. - For each λ, solve
(A − λI)·v = 0to find the eigenvector v.
Worked example
A = [[4, 1], [2, 3]]
det(A − λI) = (4−λ)(3−λ) − 2 = λ² − 7λ + 10 = 0gives λ = 5 and λ = 2.- For λ = 5:
(A − 5I)v = 0givesv = [1, 1]. Check:A·[1,1] = [5,5] = 5·[1,1]✅ - For λ = 2: gives
v = [1, −2]. Check:A·[1,−2] = [2,−4] = 2·[1,−2]✅
Handy facts
- Sum of eigenvalues = trace (sum of the diagonal). Here 5 + 2 = 7 = 4 + 3 ✅
- Product of eigenvalues = determinant. Here 5 × 2 = 10 = det(A) ✅
- A matrix is singular (non-invertible) if any eigenvalue is 0.
- Symmetric matrices have real eigenvalues and orthogonal eigenvectors.
Eigendecomposition
A diagonalizable matrix can be written as:
A = V · Λ · V⁻¹
where the columns of V are eigenvectors and Λ is a diagonal matrix of eigenvalues. For a symmetric matrix this becomes A = Q·Λ·Qᵀ.
🧠 Why ML cares
The eigenvectors of a data covariance matrix point along the directions of greatest variation, and the eigenvalues say how much variation lies along each. This is the heart of Principal Component Analysis (PCA).
1.10 Singular Value Decomposition (SVD)
Eigendecomposition only works for certain square matrices. SVD works for any matrix (any shape), which makes it extremely general and powerful.
A = U · Σ · Vᵀ
| Piece | Shape (for A of size m × n) | Meaning |
|---|---|---|
| U | m × m | Orthogonal matrix of left singular vectors |
| Σ (Sigma) | m × n | Diagonal matrix of singular values σ₁ ≥ σ₂ ≥ … ≥ 0 |
| Vᵀ | n × n | Orthogonal matrix of right singular vectors (transposed) |
Intuition: Any linear transformation can be broken into three simple steps: rotate (Vᵀ), stretch along the axes (Σ), then rotate again (U).
Connection to eigenvalues: The singular values of A are the square roots of the eigenvalues of AᵀA.
Low-rank approximation
Keep only the largest k singular values (set the rest to zero). You get the best rank-k approximation of A:
A ≈ Uₖ · Σₖ · Vₖᵀ
This stores far fewer numbers while keeping the most important structure.
🧠 Why ML cares
- Dimensionality reduction (PCA can be computed via SVD)
- Data and image compression
- Noise reduction (small singular values often correspond to noise)
- Recommender systems (matrix factorization)
- Handling rank-deficient or ill-conditioned matrices
2. Calculus
The big idea: Calculus tells us how things change. In ML, we constantly ask: "If I nudge this parameter slightly, how much does my error change?" The answer is a derivative, and it tells us which way to adjust the model.
2.1 Limits
A limit describes the value a function approaches as the input gets closer and closer to some point, even if it never actually reaches it.
lim f(x) = L
x→a
"As x gets close to a, f(x) gets close to L."
Example: lim(x→2) (x² − 4)/(x − 2). Plugging in x = 2 gives 0/0, which is undefined. But (x²−4)/(x−2) = (x+2), so the limit is 4.
🧠 Why it matters
Limits are the foundation for the derivative. Without them, "instantaneous rate of change" has no precise meaning. Limits as x → ∞ also describe the long-term behaviour of functions (e.g., a sigmoid flattening toward 1).
2.2 Derivatives
The derivative of f at a point is the instantaneous rate of change, the slope of the tangent line at that point.
f′(x) = lim [ f(x + h) − f(x) ] / h
h→0
Reading the derivative
| Sign of f′(x) | Meaning |
|---|---|
| Positive | f is increasing at x |
| Negative | f is decreasing at x |
| Zero | f is flat at x, a possible minimum, maximum, or saddle point |
Common derivative rules
| Rule | Formula |
|---|---|
| Constant | d/dx (c) = 0 |
| Power | d/dx (xⁿ) = n·xⁿ⁻¹ |
| Constant multiple | d/dx (c·f) = c·f′ |
| Sum | d/dx (f + g) = f′ + g′ |
| Product | d/dx (f·g) = f′·g + f·g′ |
| Quotient | d/dx (f/g) = (f′·g − f·g′) / g² |
| Exponential | d/dx (eˣ) = eˣ |
| Natural log | d/dx (ln x) = 1/x |
| Sine / Cosine | d/dx (sin x) = cos x, d/dx (cos x) = −sin x |
Example: f(x) = 3x² + 2x gives f′(x) = 6x + 2. At x = 1, the slope is 8.
Derivatives of functions common in ML
- Sigmoid
σ(x) = 1/(1+e⁻ˣ)has derivativeσ(x)·(1 − σ(x)) - tanh has derivative
1 − tanh²(x) - ReLU
max(0, x)has derivative0for x < 0 and1for x > 0
2.3 Partial Derivatives
Most ML functions depend on many variables (many parameters). A partial derivative measures the change in the function when you vary one variable while holding all others constant.
Written ∂f/∂x.
Example: f(x, y) = x²y + 3y
∂f/∂x = 2xy(treat y as a constant)∂f/∂y = x² + 3(treat x as a constant)
🧠 Why ML cares
A model has thousands or millions of parameters. A partial derivative tells us how the loss changes with respect to each one individually.
2.4 Chain Rule
The chain rule handles functions inside functions (composition). If y = f(g(x)):
dy/dx = f′(g(x)) · g′(x)
Or, with u = g(x): dy/dx = (dy/du) · (du/dx). "Derivative of the outside × derivative of the inside."
Example: y = (3x + 1)². Let u = 3x + 1, so y = u².
dy/dx = 2u · 3 = 6(3x + 1).
Multivariable chain rule
If z = f(x, y) where x = x(t) and y = y(t):
dz/dt = (∂f/∂x)(dx/dt) + (∂f/∂y)(dy/dt)
🧠 Why ML cares
A neural network is a long chain of functions (layer after layer). Backpropagation is simply the chain rule applied repeatedly, passing error derivatives backward from the output through each layer to find how every weight affects the loss.
2.5 Gradients
For a function of many variables, the gradient collects all the partial derivatives into one vector:
[ ∂f/∂x₁ ]
∇f(x) = [ ∂f/∂x₂ ]
[ ⋮ ]
[ ∂f/∂xₙ ]
Two key properties
- The gradient points in the direction of steepest increase of f.
- The negative gradient (−∇f) points in the direction of steepest decrease.
Example: f(x, y) = x² + 3y gives ∇f = [2x, 3]. At the point (1, 5), the gradient is [2, 3].
Magnitude ‖∇f‖ tells you how steep the slope is. At a minimum, ∇f = 0.
Loss
│\ /
│ \ /
│ \ / ← gradient points uphill,
│ \__ __/ we step the OPPOSITE way (downhill)
│ \/
└────────────── parameter
2.6 Jacobian
When a function has many inputs and many outputs (a vector-valued function f: ℝⁿ → ℝᵐ), the Jacobian is the matrix of all first-order partial derivatives:
[ ∂f₁/∂x₁ ∂f₁/∂x₂ … ∂f₁/∂xₙ ]
J = [ ∂f₂/∂x₁ ∂f₂/∂x₂ … ∂f₂/∂xₙ ] (shape m × n)
[ ⋮ ⋮ ⋮ ]
[ ∂fₘ/∂x₁ ∂fₘ/∂x₂ … ∂fₘ/∂xₙ ]
Each row is the gradient of one output function.
Example: f(x, y) = [x²y, 5x + sin y]
J = [ 2xy x² ]
[ 5 cos y ]
Notes
- For a scalar function (m = 1), the Jacobian is just the gradient written as a row.
- The Jacobian is the best linear approximation of f near a point.
- The chain rule in matrix form: Jacobian of a composition = product of Jacobians.
🧠 Why ML cares
Layers of neural networks map vectors to vectors. Their Jacobians describe how each output changes with each input, and are what backpropagation effectively multiplies together.
2.7 Hessian
The Hessian is the matrix of all second-order partial derivatives of a scalar function. It describes the curvature of the function.
[ ∂²f/∂x₁² ∂²f/∂x₁∂x₂ … ∂²f/∂x₁∂xₙ ]
H = [ ∂²f/∂x₂∂x₁ ∂²f/∂x₂² … ∂²f/∂x₂∂xₙ ] (shape n × n)
[ ⋮ ⋮ ⋮ ]
[ ∂²f/∂xₙ∂x₁ ∂²f/∂xₙ∂x₂ … ∂²f/∂xₙ² ]
(The Hessian is the Jacobian of the gradient.) It is symmetric when the second derivatives are continuous.
Example: f(x, y) = x² + xy + y²
∇f = [2x + y, x + 2y] H = [2 1]
[1 2]
Using the Hessian to classify a critical point (where ∇f = 0)
| Hessian's eigenvalues | Hessian type | The point is a… |
|---|---|---|
| All positive | Positive definite | Local minimum (bowl-shaped) |
| All negative | Negative definite | Local maximum (hill-shaped) |
| Mixed signs | Indefinite | Saddle point |
| Some zero | Semi-definite | Test inconclusive |
For the example above, the eigenvalues of H are 3 and 1, both positive, so f is convex (bowl-shaped).
🧠 Why ML cares
Curvature tells us how quickly the slope itself changes, which guides step sizes. Second-order optimization methods (like Newton's method) use the Hessian, though it is expensive for models with millions of parameters.
2.8 Gradient-Based Optimization (Preview)
Putting calculus to work: to minimize a function, repeatedly move opposite to the gradient.
new parameters = old parameters − (step size) × gradient
This is the core idea of gradient descent, covered in full in Section 5. Calculus supplies the direction (the gradient), and optimization supplies the strategy for using it.
3. Probability
The big idea: The real world is noisy and uncertain. Probability gives us a precise language to describe uncertainty, so models can say "I'm 87% sure this is a cat" rather than just "cat."
3.1 Basic Building Blocks
| Term | Meaning | Example (rolling a die) |
|---|---|---|
| Experiment | A process with an uncertain outcome | Rolling the die |
| Sample space (Ω or S) | The set of all possible outcomes | {1, 2, 3, 4, 5, 6} |
| Outcome | One single result | 4 |
| Event | A subset of the sample space | "Even number" = {2, 4, 6} |
| Probability P(A) | A number from 0 to 1 measuring how likely event A is | P(even) = 3/6 = 0.5 |
The three axioms (rules) of probability
P(A) ≥ 0for any event A (never negative)P(Ω) = 1(something must happen)- For mutually exclusive events,
P(A ∪ B) = P(A) + P(B)
Handy rules
- Complement:
P(not A) = 1 − P(A) - Addition rule (general):
P(A or B) = P(A) + P(B) − P(A and B) - Equally likely outcomes:
P(A) = (favourable outcomes) / (total outcomes)
3.2 Random Variables
A random variable (RV) is a variable whose value is a numerical outcome of a random process. It's a rule that attaches a number to each outcome.
Example: Flip 3 coins. Let X = number of heads. X can be 0, 1, 2 or 3.
Two types
| Discrete RV | Continuous RV | |
|---|---|---|
| Values | Countable (0, 1, 2…) | Any value in a range (height, time) |
| Described by | PMF (Probability Mass Function) | PDF (Probability Density Function) |
| Probability of an exact value | P(X = x) can be > 0 | P(X = x) = 0 for any single exact value |
| Probability over a range | Sum the PMF | Area under the PDF curve |
| Total | Sum of all probabilities = 1 | Total area under the curve = 1 |
CDF (Cumulative Distribution Function)
Works for both types: F(x) = P(X ≤ x), the probability the variable is at most x. It rises from 0 to 1.
⚠️ A PDF value is a density, not a probability, so it can exceed 1. Only the area under it is a probability.
3.3 Conditional Probability
The probability of event A given that event B has already happened:
P(A | B) = P(A and B) / P(B) (requires P(B) > 0)
Knowing B shrinks the sample space to just B, and we ask what fraction of B is also A.
Example: Draw one card from a standard deck. Given the card is a face card (J, Q, K: 12 cards), what's the chance it's a King?
P(King | Face) = 4/12 = 1/3.
Multiplication rule (chain rule of probability)
P(A and B) = P(A | B) · P(B) = P(B | A) · P(A)
For many events: P(A,B,C) = P(A) · P(B|A) · P(C|A,B)
Law of total probability
If B₁, B₂, … Bₙ split the sample space into non-overlapping pieces:
P(A) = Σ P(A | Bᵢ) · P(Bᵢ)
3.4 Bayes' Theorem
Lets us flip a conditional probability, going from P(B | A) to P(A | B):
P(B | A) · P(A)
P(A | B) = ─────────────────
P(B)
In "learning" language:
Posterior = (Likelihood × Prior) / Evidence
P(hypothesis | data) = P(data | hypothesis) · P(hypothesis) / P(data)
| Term | Meaning |
|---|---|
Prior P(A) | What you believed before seeing the data |
Likelihood P(B|A) | How probable the data is if the hypothesis is true |
Evidence P(B) | Overall probability of the data (a normalising factor) |
Posterior P(A|B) | Your updated belief after seeing the data |
Worked example: the medical test
- 1% of people have a disease:
P(D) = 0.01 - Test is positive for 90% of sick people:
P(+|D) = 0.90 - Test is wrongly positive for 5% of healthy people:
P(+|¬D) = 0.05
Evidence:
P(+) = 0.90 × 0.01 + 0.05 × 0.99 = 0.009 + 0.0495 = 0.0585
Posterior:
P(D|+) = (0.90 × 0.01) / 0.0585 ≈ 0.154
So even after a positive test, there is only about a 15.4% chance of actually having the disease. The disease is so rare that false positives outnumber true positives. Ignoring the prior (the base rate) is a classic mistake.
🧠 Why ML cares
Bayes' theorem underlies Naive Bayes classifiers, Bayesian inference, and the idea of updating beliefs as more data arrives.
3.5 Independence
Two events are independent if knowing one tells you nothing about the other.
Independent ⟺ P(A and B) = P(A) · P(B)
⟺ P(A | B) = P(A)
Example: Two separate coin flips are independent. P(both heads) = 0.5 × 0.5 = 0.25.
Related ideas
- Mutually exclusive ≠ independent. Mutually exclusive events can't happen together. If one happens, the other definitely doesn't, so they're actually strongly dependent.
- Conditional independence: A and B are independent given C if
P(A, B | C) = P(A|C) · P(B|C). Naive Bayes assumes features are conditionally independent given the class. - i.i.d. ("independent and identically distributed"): the usual assumption that data samples are drawn independently from the same distribution.
3.6 Expectation (Expected Value)
The long-run average value of a random variable, a probability-weighted average of its possible values.
Discrete: E[X] = Σ x · P(X = x)
Continuous: E[X] = ∫ x · f(x) dx
Example (fair die): E[X] = (1+2+3+4+5+6)/6 = 3.5.
(You never roll a 3.5, but it's the average over many rolls.)
Properties
- Linearity:
E[aX + b] = a·E[X] + b E[X + Y] = E[X] + E[Y](always true, even if X and Y are dependent)- If X and Y are independent:
E[X·Y] = E[X]·E[Y] - For a function:
E[g(X)] = Σ g(x) · P(X = x)
3.7 Variance and Standard Deviation
Variance measures how spread out a random variable is around its mean.
Var(X) = E[(X − μ)²] = E[X²] − (E[X])²
Standard deviation: σ = √Var(X), in the same units as X.
Example (fair die): E[X²] = (1+4+9+16+25+36)/6 = 91/6 ≈ 15.17, and (E[X])² = 12.25, so Var = 15.17 − 12.25 ≈ 2.92 and σ ≈ 1.71.
Properties
Var(aX + b) = a² · Var(X)(adding a constant doesn't change spread)- If X and Y are independent:
Var(X + Y) = Var(X) + Var(Y)
3.8 Covariance
Measures how two random variables vary together:
Cov(X, Y) = E[(X − μₓ)(Y − μᵧ)] = E[XY] − E[X]·E[Y]
| Cov(X, Y) | Interpretation |
|---|---|
| > 0 | When X is above its mean, Y tends to be above its mean too |
| < 0 | When X is above its mean, Y tends to be below its mean |
| ≈ 0 | No linear relationship |
Notes
Cov(X, X) = Var(X)- Independent variables have
Cov = 0, but zero covariance doesn't guarantee independence (it only rules out a linear relationship). - The size of covariance depends on units, so it's hard to compare. That's why we use correlation (see Statistics).
Covariance matrix
For a vector of d variables, the covariance matrix Σ is a d × d matrix whose entry (i, j) is Cov(Xᵢ, Xⱼ). The diagonal holds the variances. It is symmetric and positive semi-definite.
3.9 Probability Distributions
A probability distribution describes how probability is spread over the possible values of a random variable.
Discrete distributions
| Distribution | Describes | Parameters | Mean / Variance | Example |
|---|---|---|---|---|
| Bernoulli | One yes/no trial | p (success prob.) | p / p(1−p) | A single coin flip, one click/no-click |
| Binomial | Number of successes in n independent Bernoulli trials | n, p | np / np(1−p) | Heads in 10 flips |
| Categorical | One trial with k possible outcomes | probabilities p₁…pₖ | , | A single die roll, class prediction |
| Poisson | Count of events in a fixed interval | λ (average rate) | λ / λ | Emails received per hour |
Continuous distributions
| Distribution | Describes | Parameters | Mean / Variance |
|---|---|---|---|
| Uniform | All values in [a, b] equally likely | a, b | (a+b)/2 / (b−a)²/12 |
| Gaussian (Normal) | The famous bell curve | μ (mean), σ² (variance) | μ / σ² |
| Exponential | Waiting time between random events | λ (rate) | 1/λ / 1/λ² |
The Gaussian (Normal) distribution: the star of ML
f(x) = 1 / (σ√(2π)) · exp( −(x − μ)² / (2σ²) )
Written X ~ N(μ, σ²).
.-'''-.
.' '. ← peak at the mean μ
.' '.
_.' '._ ← spread controlled by σ
───────────────────────
μ−2σ μ−σ μ μ+σ μ+2σ
The 68–95–99.7 rule
- ≈ 68% of values fall within 1σ of the mean
- ≈ 95% within 2σ
- ≈ 99.7% within 3σ
Standard normal: N(0, 1). Any normal variable can be converted to it by the z-score: z = (x − μ) / σ.
Why it's everywhere: The Central Limit Theorem says the average of many independent random variables tends toward a normal distribution, regardless of the original distribution. Many natural measurements, and many noise assumptions in ML, are therefore treated as Gaussian.
Multivariate Gaussian: The extension to many variables, described by a mean vector μ and a covariance matrix Σ.
4. Statistics
The big idea: Probability goes from a known model to data. Statistics goes the other way: from observed data back to conclusions about the underlying world. It's how we summarise data, estimate things, and decide whether results are real or just luck.
4.1 Descriptive Statistics: Summarising Data
We'll use this tiny dataset throughout: 2, 3, 3, 5, 7
Measures of central tendency ("the typical value")
| Measure | Definition | Our data |
|---|---|---|
| Mean (average) | Sum ÷ count: x̄ = (Σxᵢ) / n | (2+3+3+5+7)/5 = 4 |
| Median | The middle value when sorted (average the two middle values if the count is even) | 3 |
| Mode | The most frequent value | 3 |
When to use which
- Mean: uses every value, but is sensitive to outliers.
- Median: robust to outliers. Better for skewed data like incomes or house prices.
- Mode: the only one that works for categorical data (e.g., most common colour).
Example: for
2, 3, 3, 5, 100, the mean jumps to 22.6, while the median stays at 3.
In a symmetric distribution, mean ≈ median ≈ mode. In a right-skewed one, mean > median > mode.
Measures of spread ("how scattered is the data")
| Measure | Formula | Our data |
|---|---|---|
| Range | max − min | 7 − 2 = 5 |
| Variance | Average squared distance from the mean | see below |
| Standard deviation | √variance | see below |
Population vs sample formulas
Population variance: σ² = Σ(xᵢ − μ)² / N
Sample variance: s² = Σ(xᵢ − x̄)² / (n − 1)
With data 2,3,3,5,7 and mean 4, the squared deviations are 4, 1, 1, 1, 9 (sum = 16):
- Population:
σ² = 16/5 = 3.2,σ ≈ 1.79 - Sample:
s² = 16/4 = 4,s = 2
Why n − 1 for samples? (Bessel's correction): A sample's values tend to sit closer to the sample mean than to the true mean, so dividing by n would underestimate the true variance. Dividing by n − 1 corrects this bias.
Standard deviation is easier to interpret than variance because it's in the same units as the data.
Other spread measures: Interquartile range (IQR) = Q3 − Q1 (the spread of the middle 50%), useful for spotting outliers.
4.2 Covariance and Correlation
Sample covariance:
Cov(X, Y) = Σ (xᵢ − x̄)(yᵢ − ȳ) / (n − 1)
(Interpretation is the same as in Section 3.8.)
Correlation (Pearson's r)
Covariance rescaled to be unit-free and bounded between −1 and +1:
r = Cov(X, Y) / (σₓ · σᵧ)
| r value | Meaning |
|---|---|
| +1 | Perfect positive linear relationship |
| 0 | No linear relationship |
| −1 | Perfect negative linear relationship |
| ≈ ±0.7 or more | Commonly considered a strong linear relationship |
Important cautions
- ⚠️ Correlation ≠ causation. Ice-cream sales and drowning both rise in summer, but one doesn't cause the other (a hidden third factor, the weather, drives both).
- Pearson's r only captures linear relationships. A perfect curve (like y = x²) can have r ≈ 0.
- It's sensitive to outliers.
- A correlation matrix shows pairwise correlations among many variables. It helps spot redundant features.
4.3 Population vs Sample
| Population | Sample | |
|---|---|---|
| Definition | The entire group you care about | A subset drawn from the population |
| Size | Often huge or impossible to fully measure | Manageable |
| Numbers describing it are called | Parameters (μ, σ) | Statistics (x̄, s) |
| Example | All users of an app | 1,000 randomly chosen users |
We almost never observe the whole population, so we use sample statistics to estimate population parameters.
Sampling methods
| Method | How it works |
|---|---|
| Simple random | Every member has an equal chance of being picked |
| Stratified | Split population into groups (strata) and sample from each |
| Systematic | Pick every k-th member from a list |
| Cluster | Randomly choose whole groups and include everyone in them |
| Convenience | Pick whoever is easy to reach (often biased) |
Sampling bias: if the sample doesn't represent the population (e.g., surveying only people who answer phone calls), conclusions will be skewed. A larger, truly random sample reduces error.
Sampling distribution and the Central Limit Theorem
If you take many samples and compute each one's mean, those means form a sampling distribution.
- Its mean equals the population mean μ.
- Its spread is the standard error:
SE = σ / √n. It shrinks as sample size n grows. - By the Central Limit Theorem, for large n this distribution is approximately normal, even if the population isn't.
4.4 Hypothesis Testing
A formal procedure for deciding whether the evidence in a sample is strong enough to reject a default assumption. Think of it like a courtroom: innocent until proven guilty beyond reasonable doubt.
The ingredients
| Term | Meaning |
|---|---|
| Null hypothesis (H₀) | The default claim: "no effect / no difference" |
| Alternative hypothesis (H₁ or Hₐ) | What you suspect instead: "there is an effect / difference" |
| Test statistic | A number computed from the data that measures how far it is from what H₀ predicts (z, t, χ², F…) |
| Significance level (α) | The threshold for "surprising enough" (commonly 0.05) |
| p-value | See Section 4.6 |
| Decision | Reject H₀ or fail to reject H₀ |
The step-by-step process
- State H₀ and H₁.
- Choose the significance level α (e.g., 0.05).
- Collect data and compute the test statistic.
- Compute the p-value (or compare the statistic to a critical value).
- Decide: if
p ≤ α, reject H₀. Otherwise, fail to reject H₀.
Note the careful wording: we say "fail to reject" H₀, not "accept" it. Lack of evidence isn't proof of no effect.
One-tailed vs two-tailed tests
- Two-tailed: H₁ says "different" (≠). Looks for extreme values in both directions.
- One-tailed: H₁ says "greater than" or "less than". Looks in one direction only.
Types of errors
| H₀ is actually true | H₀ is actually false | |
|---|---|---|
| Reject H₀ | ❌ Type I error (false positive), probability = α | ✅ Correct (probability = power, 1 − β) |
| Fail to reject H₀ | ✅ Correct | ❌ Type II error (false negative), probability = β |
- Power (1 − β): the chance of correctly detecting a real effect. It increases with larger sample sizes and larger true effects.
- Lowering α reduces Type I errors but tends to increase Type II errors.
Common tests (names to recognise)
- z-test / t-test: compare means (t-test when σ is unknown, especially for small samples)
- Chi-square test: relationships between categorical variables
- ANOVA (F-test): compare means across 3+ groups
Quick example (z-test)
A website's average load time was historically μ₀ = 3.0 s with known σ = 0.6 s. After an update, 36 pages averaged x̄ = 2.8 s. Did it truly improve?
- H₀: μ = 3.0 vs H₁: μ < 3.0 (one-tailed)
z = (x̄ − μ₀) / (σ/√n) = (2.8 − 3.0) / (0.6/6) = −0.2 / 0.1 = −2.0- For z = −2.0, the one-tailed p-value ≈ 0.023, which is below 0.05, so reject H₀. There is statistically significant evidence of improvement.
4.5 Confidence Intervals
A confidence interval (CI) gives a range of plausible values for a population parameter, instead of a single-point guess.
CI = point estimate ± margin of error
= x̄ ± (critical value) × (standard error)
For a mean with known σ:
x̄ ± z* · σ/√n
| Confidence level | z* (critical value) |
|---|---|
| 90% | 1.645 |
| 95% | 1.96 |
| 99% | 2.576 |
(If σ is unknown, which is typical, use the t-distribution with the sample's s instead.)
Example: Sample of n = 100, x̄ = 50, σ = 10.
SE = 10/√100 = 1, so 95% CI = 50 ± 1.96 × 1 = (48.04, 51.96).
How to interpret it correctly
✅ "If we repeated this sampling procedure many times, about 95% of the intervals built this way would contain the true population mean."
❌ It does not mean "there's a 95% probability that the true mean is in this particular interval." (The true mean is fixed. The interval is what varies.)
What changes the width?
- Larger sample size gives a narrower interval (more precision)
- Higher confidence level gives a wider interval
- More variability in the data gives a wider interval
4.6 P-Values
The p-value is the probability of getting results at least as extreme as what you observed, assuming the null hypothesis is true.
- Small p-value (≤ α, typically 0.05): the data would be very surprising if H₀ were true, so we have evidence against H₀.
- Large p-value: the data is consistent with H₀, so there's not enough evidence to reject it.
| p-value | Informal reading |
|---|---|
| < 0.01 | Strong evidence against H₀ |
| 0.01 – 0.05 | Moderate evidence against H₀ |
| > 0.05 | Little or no evidence against H₀ |
⚠️ What a p-value is NOT
- ❌ It is not the probability that H₀ is true.
- ❌ It is not the probability that the result happened "by chance" in a simple sense.
- ❌ It does not measure the size or importance of an effect. With huge samples, tiny, meaningless differences can be "significant."
- ❌
p = 0.05isn't a magic line. It's a convention.
Connection to confidence intervals
They tell a consistent story: a two-sided test at α = 0.05 rejects H₀ exactly when the 95% CI doesn't contain the null value.
Multiple testing warning
If you run many tests, some will come out "significant" by pure luck (about 1 in 20 at α = 0.05). This is called p-hacking when abused, so corrections (e.g., Bonferroni) exist.
5. Optimization
The big idea: Training a model means finding the best parameters, the ones that make the model's mistakes as small as possible. That search is an optimization problem.
5.1 Objective Functions
An objective function is the quantity we want to minimize or maximize.
minimize f(θ) (θ = the parameters we can adjust)
- When we minimize it, it's also called a cost or loss function.
- When we maximize it (e.g., likelihood, reward), it's called a utility or score.
- Maximizing
fis the same as minimizing−f, so we usually just talk about minimization.
Optimal point: θ* = argmin f(θ), the parameter values that give the lowest value.
Local vs global minimum
f(θ)
│ \ /\
│ \ /\ / \ ← local minima (valleys that aren't the lowest)
│ \/ \_/ \___
│ ↑ global minimum (the lowest point of all)
└──────────────────── θ
- Global minimum: the very lowest value across the whole domain.
- Local minimum: lowest in its neighbourhood only.
- Saddle point: gradient is zero but it's a minimum in some directions and a maximum in others.
5.2 Loss Functions
A loss function L(y, ŷ) measures how wrong a single prediction is (ŷ = prediction, y = true value). The cost function is typically the average loss over the whole dataset.
For regression (predicting numbers)
| Loss | Formula | Behaviour |
|---|---|---|
| MSE (Mean Squared Error) | (1/n) Σ (yᵢ − ŷᵢ)² | Heavily penalises large errors. Smooth and easy to differentiate |
| MAE (Mean Absolute Error) | (1/n) Σ |yᵢ − ŷᵢ| | Robust to outliers. Not smooth at 0 |
| RMSE | √MSE | Same units as the target |
| Huber loss | Quadratic for small errors, linear for large | A blend of MSE and MAE |
For classification (predicting categories)
| Loss | Formula / Idea | Notes |
|---|---|---|
| Binary cross-entropy (log loss) | −[y·log(ŷ) + (1−y)·log(1−ŷ)] | For yes/no problems with probability outputs |
| Categorical cross-entropy | −Σ yₖ · log(ŷₖ) | For multi-class problems |
| Hinge loss | max(0, 1 − y·ŷ) | Used by support-vector-style classifiers |
(Cross-entropy is explained properly in Section 6.)
Choosing a loss: the loss should reflect what you actually care about. Squared error punishes outliers harshly, absolute error shrugs them off. It also needs to be differentiable (or nearly so) for gradient-based methods.
Regularization (adding a penalty)
Add a term to the objective to discourage overly complex models:
Total objective = Loss + λ · Penalty(θ)
- L2 (Ridge): penalty =
Σ θ²(shrinks weights smoothly) - L1 (Lasso): penalty =
Σ |θ|(pushes some weights to exactly zero)
5.3 Convexity
Convex sets and functions
- A convex set: for any two points in the set, the straight line between them also lies in the set.
- A convex function: the line segment between any two points on its graph lies on or above the graph.
Mathematically, f is convex if:
f(λx + (1−λ)y) ≤ λ·f(x) + (1−λ)·f(y) for all x, y and 0 ≤ λ ≤ 1
Convex (bowl) Non-convex (bumpy)
\ / \ /\ /
\ / \ / \ /
\_____/ \/ \/
ONE valley MANY valleys
Why convexity is a big deal
For a convex function, every local minimum is a global minimum.
So gradient descent (with a suitable step size) is guaranteed to reach the best answer. No getting trapped in a bad valley.
How to check
- One variable:
f″(x) ≥ 0everywhere. - Many variables: the Hessian is positive semi-definite everywhere.
- Strictly convex: gives a unique minimum.
Examples
- ✅ Convex: MSE for linear regression, logistic regression's log loss,
x²,eˣ,|x| - ❌ Non-convex: loss surfaces of deep neural networks (full of local minima and saddle points)
Even though deep learning problems are non-convex, gradient-based methods still work well in practice.
5.4 Gradient Descent (GD)
The workhorse of ML. Walk downhill: repeatedly step in the direction opposite to the gradient.
Update rule
θ_new = θ_old − η · ∇J(θ_old)
- θ = parameters
- η (eta), also called α = learning rate (step size)
- ∇J = gradient of the cost function
Algorithm
- Initialise parameters (often randomly).
- Compute the gradient of the cost using the entire dataset.
- Update:
θ ← θ − η∇J(θ). - Repeat until the cost stops improving (convergence) or a set number of iterations is reached.
Mini example
Minimise f(w) = w² (gradient 2w), start at w = 4, learning rate η = 0.1:
| Step | w | gradient 2w | new w = w − 0.1·(2w) |
|---|---|---|---|
| 0 | 4.00 | 8.00 | 3.20 |
| 1 | 3.20 | 6.40 | 2.56 |
| 2 | 2.56 | 5.12 | 2.05 |
| … | … | … | → 0 (the minimum) |
The learning rate matters enormously
| Learning rate | What happens |
|---|---|
| Too small | Converges, but painfully slowly |
| Just right | Smooth, efficient descent |
| Too large | Overshoots, bounces around or even diverges (error explodes) |
Practical notes
- Feature scaling (making features similar in range) makes gradient descent converge much faster.
- Learning rate schedules (decreasing η over time) help settle into a minimum.
- In non-convex problems it can get stuck at local minima or saddle points or crawl across plateaus.
5.5 Stochastic Gradient Descent (SGD)
Plain (batch) gradient descent uses all the data for each step, which is slow for big datasets. SGD uses just one randomly chosen sample per update.
For each sample (xᵢ, yᵢ):
θ ← θ − η · ∇Lᵢ(θ)
Pros
- ⚡ Very fast per step and cheap on memory
- Handles huge datasets and online/streaming data
- The noise in the updates can help escape shallow local minima and saddle points
Cons
- The path is noisy and zig-zaggy because one sample is a rough estimate of the true gradient
- It tends to bounce around the minimum rather than settling exactly (fixed by decaying the learning rate)
- Can't take full advantage of vectorised hardware
Epoch: one full pass through the entire training set. Data is usually shuffled each epoch.
5.6 Mini-Batch Gradient Descent
The sweet spot between the two extremes: compute the gradient on small random groups of samples (the mini-batch, e.g., 32, 64, 128 or 256).
θ ← θ − η · (1/m) Σ(over m samples in batch) ∇Lᵢ(θ)
| Variant | Samples per update | Speed per update | Path smoothness | Memory |
|---|---|---|---|---|
| Batch GD | All N | Slow | Smooth and stable | High |
| Stochastic GD | 1 | Very fast | Very noisy | Low |
| Mini-Batch GD | m (e.g., 32–256) | Fast | Moderately smooth | Moderate |
Why it's the default in practice
- Gradient estimate is less noisy than SGD.
- Exploits matrix operations / GPUs efficiently.
- Good balance of speed and stability.
Terminology
- Batch size = samples per update
- Iteration = one parameter update
- Epoch = one full pass over the data (
N / batch sizeiterations)
ℹ️ In many libraries and papers, "SGD" loosely refers to mini-batch SGD.
Common improvements to plain gradient descent (names only)
Momentum (builds up speed in consistent directions), RMSprop, Adam (adapts the step size per parameter). These are all refinements of the same core idea.
5.7 Constrained Optimization
Sometimes the solution must obey rules. We minimise an objective subject to constraints.
minimize f(x)
subject to gᵢ(x) ≤ 0 (inequality constraints)
hⱼ(x) = 0 (equality constraints)
The set of points satisfying all constraints is the feasible region.
Example: Minimise x² + y² subject to x + y = 1. (Find the point closest to the origin that lies on the line x + y = 1. The answer is (0.5, 0.5).)
Lagrange Multipliers (equality constraints)
Combine the objective and constraints into one function, the Lagrangian:
L(x, λ) = f(x) + λ · h(x)
Set all the partial derivatives to zero: ∇ₓL = 0 and ∂L/∂λ = 0 (which just recovers the constraint).
Worked example: minimise f = x² + y² subject to x + y − 1 = 0.
L = x² + y² + λ(x + y − 1)
∂L/∂x = 2x + λ = 0 → x = −λ/2
∂L/∂y = 2y + λ = 0 → y = −λ/2
x + y = 1 → −λ = 1 → λ = −1
⇒ x = y = 0.5
Intuition: At the optimum, the gradient of the objective is parallel to the gradient of the constraint. You can't improve without breaking the constraint. The multiplier λ measures how sensitive the optimal value is to the constraint.
Inequality constraints and the KKT conditions
For inequality constraints, the Karush–Kuhn–Tucker (KKT) conditions generalise Lagrange multipliers. At a constrained optimum:
- Stationarity: the gradient of the Lagrangian is zero.
- Primal feasibility: all constraints are satisfied.
- Dual feasibility: multipliers for inequality constraints are ≥ 0.
- Complementary slackness: each multiplier × its constraint = 0. An inequality constraint is either active (touching the boundary) or its multiplier is zero (not affecting the solution).
For convex problems, the KKT conditions are both necessary and sufficient for optimality.
Other practical ways to handle constraints
- Projected gradient descent: take a normal gradient step, then project the result back onto the feasible region.
- Penalty methods: add a large penalty for violating a constraint into the objective.
🧠 Why ML cares
Constraints appear when probabilities must sum to 1, when weights must be non-negative, or when limiting model size. Regularization can also be viewed as a constrained problem (e.g., "keep the weights' norm below some budget").
6. Information Theory
The big idea: Information theory gives us a way to measure uncertainty and surprise. It's behind the loss functions used in classification and the splitting rules of decision trees.
Intuition: A rare, surprising event carries more information than a common, expected one. "The sun rose today" tells you nothing. "It snowed in the desert" tells you a lot.
6.1 Information Content (Surprise)
The information (or surprise) of an event with probability p:
I(x) = −log₂ p(x) (measured in bits)
- Certain event (p = 1):
I = 0bits (no surprise) - Fair coin lands heads (p = 0.5):
I = 1bit - Event with p = 1/8:
I = 3bits
(Using natural log gives units of nats instead of bits.)
6.2 Entropy
Entropy is the average surprise (average information) of a random variable. It measures the uncertainty or impurity/disorder in a distribution.
H(X) = − Σ p(x) · log₂ p(x)
(By convention, 0 · log 0 = 0.)
Examples
| Distribution | Entropy | Why |
|---|---|---|
| Fair coin (0.5, 0.5) | 1 bit | Maximum uncertainty for 2 outcomes |
| Biased coin (0.9, 0.1) | −0.9·log₂0.9 − 0.1·log₂0.1 ≈ 0.469 bits | More predictable |
| Certain outcome (1, 0) | 0 bits | No uncertainty |
| Fair 8-sided die | 3 bits | log₂ 8 |
Key facts
H(X) ≥ 0- Minimum (0) when one outcome is certain.
- Maximum when all outcomes are equally likely (uniform):
log₂ nfor n outcomes. - Interpretation: the minimum average number of bits needed to encode outcomes drawn from the distribution.
Entropy
1 │ ___
│ .' '.
│ .' '.
│ .' '.
0 └──┴─────┴───────┴── p (probability of heads)
0 0.5 1
(peak at fair coin)
6.3 Cross-Entropy
Suppose the true distribution is P but you use a model's predicted distribution Q. Cross-entropy is the average number of bits needed to encode data from P when using a code optimised for Q.
H(P, Q) = − Σ p(x) · log q(x)
Key points
H(P, Q) ≥ H(P), and equality holds only when Q = P.- It's not symmetric:
H(P, Q) ≠ H(Q, P). - The extra cost = KL divergence:
H(P, Q) = H(P) + D_KL(P ‖ Q).
🧠 Why ML cares: the classification loss
For classification, P is the true label distribution (one-hot: 1 for the correct class, 0 elsewhere) and Q is the model's predicted probabilities. The cross-entropy loss then simplifies to:
Loss = − log( predicted probability of the correct class )
Example: True class is "cat". Model predicts [cat 0.7, dog 0.2, bird 0.1].
Loss = −ln(0.7) ≈ 0.357.
If it had predicted cat with 0.1, the loss would be −ln(0.1) ≈ 2.303, a much bigger penalty for being confidently wrong.
Binary version: −[y·log(ŷ) + (1−y)·log(1−ŷ)].
Minimising cross-entropy is equivalent to maximising the likelihood of the data under the model.
6.4 KL Divergence (Kullback–Leibler Divergence)
Measures how different one probability distribution Q is from a reference distribution P. It's the "extra bits" wasted by using Q instead of P.
D_KL(P ‖ Q) = Σ p(x) · log( p(x) / q(x) )
= H(P, Q) − H(P)
Properties
D_KL(P ‖ Q) ≥ 0always.D_KL(P ‖ Q) = 0only if P = Q.- ⚠️ Not symmetric:
D_KL(P ‖ Q) ≠ D_KL(Q ‖ P), so it's a "divergence", not a true distance. - Infinite if Q gives zero probability to something P considers possible.
Why it's useful
- Since H(P) is fixed by the data, minimising cross-entropy = minimising KL divergence between the true and predicted distributions.
- Used to measure how close a model's distribution is to the truth, and in variational methods (as a regulariser that keeps one distribution close to another).
6.5 Mutual Information
Measures how much knowing one variable reduces uncertainty about another: how much information X and Y share.
I(X; Y) = H(X) − H(X | Y)
= H(X) + H(Y) − H(X, Y)
= D_KL( P(X,Y) ‖ P(X)·P(Y) )
Properties
I(X; Y) ≥ 0I(X; Y) = 0if and only if X and Y are independent- Symmetric:
I(X; Y) = I(Y; X) - Higher value means stronger dependence
Intuition: If knowing today's cloud cover greatly reduces your uncertainty about rain, then cloud cover and rain have high mutual information.
Compared with correlation
| Correlation | Mutual information | |
|---|---|---|
| Detects | Linear relationships only | Any kind of dependency (linear or non-linear) |
| Range | −1 to +1 | 0 to ∞ |
| Direction (+/−) | Yes | No |
🧠 Why ML cares
Used for feature selection (keep features that share a lot of information with the target) and appears in the theory behind decision tree splitting.
6.6 Information Gain
Information gain is the reduction in entropy achieved by splitting data on a feature. It's the key criterion for choosing splits in decision trees.
IG(S, A) = H(S) − Σ ( |Sᵥ| / |S| ) · H(Sᵥ)
where S is the dataset, A is the feature we split on, and each Sᵥ is the subset for one value of A. The sum is the weighted average entropy after the split.
(Information gain is exactly the mutual information between the feature and the label.)
Worked example
Parent node: 10 samples, 5 positive / 5 negative, so H = 1 bit.
Split on a feature into:
- Left (4 samples): 4 positive, 0 negative, so
H = 0 - Right (6 samples): 1 positive, 5 negative, so
H = −(1/6)log₂(1/6) − (5/6)log₂(5/6) ≈ 0.650
Weighted entropy after split = (4/10)(0) + (6/10)(0.650) ≈ 0.390
IG = 1 − 0.390 = 0.610 bits
A high gain means the split made the groups much purer. The tree picks the feature with the highest information gain.
6.7 Gini Impurity
An alternative measure of node impurity used by decision trees (e.g., the CART algorithm). It's the probability of misclassifying a randomly chosen element if you labelled it randomly according to the node's class distribution.
Gini = 1 − Σ pₖ²
| Node composition | Gini |
|---|---|
| 100% one class (pure) | 1 − 1² = 0 |
| 50% / 50% (binary, most impure) | 1 − (0.25 + 0.25) = 0.5 |
| 90% / 10% | 1 − (0.81 + 0.01) = 0.18 |
- Range (binary case): 0 (pure) to 0.5 (worst). In general, the max is
1 − 1/kfor k classes. - A split is chosen to minimise the weighted Gini of the child nodes (equivalently, maximise the Gini decrease).
Gini vs Entropy
| Entropy | Gini Impurity | |
|---|---|---|
| Formula | −Σ p log₂ p | 1 − Σ p² |
| Binary max value | 1 | 0.5 |
| Computation | Uses a logarithm (slightly slower) | Simple squares (slightly faster) |
| Results in practice | Very similar trees in most cases | Very similar trees in most cases |
| Used with | Information gain (ID3, C4.5) | CART |
Information-theory family at a glance
Entropy H(P) → uncertainty in one distribution
Cross-Entropy H(P,Q) → cost of using Q to describe P = H(P) + KL
KL Divergence → extra cost / difference between P and Q
Mutual Information → shared information between X and Y
Information Gain → entropy reduction from a split (= mutual info of feature & label)
Gini Impurity → alternative to entropy for measuring node impurity
7. Quick Revision Cheat Sheet
🔷 Linear Algebra
| Concept | One-liner |
|---|---|
| Scalar / Vector / Matrix / Tensor | 0-D / 1-D / 2-D / n-D arrays of numbers |
| Matrix multiply | (m×n)·(n×p) = (m×p); not commutative |
| Transpose | Swap rows and columns; (AB)ᵀ = BᵀAᵀ |
| Inverse | A·A⁻¹ = I; exists only if square and det ≠ 0 |
| Determinant | Volume-scaling factor; 0 means singular |
| Rank | Number of independent rows/columns |
| Dot product | a·b = ‖a‖‖b‖cosθ; 0 means orthogonal |
| Eigen | Av = λv; directions that only get stretched |
| SVD | A = UΣVᵀ; works for any matrix |
| Vector space | Closed under addition and scaling; has a basis and dimension |
🔷 Calculus
| Concept | One-liner |
|---|---|
| Limit | Value a function approaches |
| Derivative | Slope / instantaneous rate of change |
| Partial derivative | Derivative w.r.t. one variable, others fixed |
| Chain rule | dy/dx = (dy/du)(du/dx); the basis of backpropagation |
| Gradient | Vector of partials; points to steepest increase |
| Jacobian | Matrix of first derivatives (vector → vector function) |
| Hessian | Matrix of second derivatives (curvature) |
🔷 Probability
| Concept | One-liner |
|---|---|
| Conditional | P(A|B) = P(A∩B)/P(B) |
| Bayes | P(A|B) = P(B|A)P(A)/P(B) |
| Independence | P(A∩B) = P(A)P(B) |
| Expectation | Probability-weighted average |
| Variance | E[X²] − (E[X])² |
| Covariance | How two variables move together |
| Gaussian | Bell curve; 68–95–99.7 rule |
🔷 Statistics
| Concept | One-liner |
|---|---|
| Mean / Median / Mode | Average / middle / most frequent |
| Std deviation | Typical distance from the mean; sample uses n−1 |
| Correlation | Standardised covariance in [−1, 1]; ≠ causation |
| Population vs sample | Whole group vs subset; parameters vs statistics |
| Hypothesis test | Reject H₀ if p ≤ α |
| Confidence interval | Range of plausible values for a parameter |
| p-value | P(data this extreme | H₀ true), not P(H₀ true) |
🔷 Optimization
| Concept | One-liner |
|---|---|
| Objective / loss | What we minimise / error on predictions |
| Convex | Any local minimum is the global minimum |
| Gradient descent | θ ← θ − η∇J; uses all data |
| SGD | One sample per update; fast but noisy |
| Mini-batch GD | Small batches; the practical default |
| Constrained optimization | Lagrange multipliers and KKT conditions |
🔷 Information Theory
| Concept | Formula |
|---|---|
| Entropy | H = −Σ p log p |
| Cross-entropy | H(P,Q) = −Σ p log q |
| KL divergence | Σ p log(p/q) (not symmetric, ≥ 0) |
| Mutual information | H(X) − H(X|Y) |
| Information gain | Parent entropy − weighted child entropy |
| Gini impurity | 1 − Σ p² |
✅ Final Takeaways
- Linear algebra is how data and models are stored and transformed.
- Calculus tells us how to nudge parameters to reduce error (gradients and the chain rule).
- Probability models uncertainty. Bayes' theorem updates beliefs with data.
- Statistics lets us draw careful conclusions from limited samples.
- Optimization turns all of this into an algorithm: follow the gradient downhill.
- Information theory supplies the loss functions (cross-entropy) and split criteria (entropy, Gini) used all over ML.
#Python for Machine Learning#
Friendly, beginner-to-advanced notes on the Python skills and libraries you need for Machine Learning. Every section has short, runnable code, plain-English explanations, and a note on why ML cares.
📑 Table of Contents
- Python Fundamentals and Functions
- Object-Oriented Programming Basics
- NumPy: Arrays and Vectorization
- Pandas: Series and DataFrames
- Data Cleaning and Manipulation
- Matplotlib and Seaborn Visualization
- SciPy for Scientific Computing
- Scikit-learn Fundamentals
- Jupyter Notebook and Google Colab
- Virtual Environments and Package Management
- Quick Revision Cheat Sheet
🗺️ How the pieces fit together
Python (language) → the glue that holds everything together
NumPy → fast numeric arrays (the foundation)
Pandas → tables of data built on NumPy
Matplotlib / Seaborn → see your data
SciPy → scientific algorithms (stats, optimization, ...)
Scikit-learn → machine learning models and tools
Jupyter / Colab → where you write, run and explain code interactively
Virtual environments → keep each project's packages tidy and separate
A typical ML workflow touches all of them:
Load data (Pandas) → Clean (Pandas) → Explore (Seaborn/Matplotlib) → Prepare arrays (NumPy)
→ Train & evaluate (Scikit-learn) → Share results (Jupyter)
1. Python Fundamentals and Functions
Python is the most popular language for ML because it is easy to read, has a huge ecosystem of libraries, and lets you go from idea to experiment quickly.
1.1 Variables and Data Types
A variable is a name that points to a value. Python figures out the type automatically (dynamic typing).
age = 25 # int
height = 1.75 # float
name = "Asha" # str
is_student = True # bool
nothing = None # NoneType (means "no value")
print(type(age)) # <class 'int'>
| Type | Example | Notes |
|---|---|---|
int | 42 | Whole numbers (no size limit) |
float | 3.14 | Decimal numbers |
str | "hello" | Text |
bool | True, False | Logical values |
NoneType | None | Represents "nothing" |
Type conversion (casting): int("5"), float(3), str(10), bool(0) (→ False).
1.2 Operators
| Kind | Operators | Example |
|---|---|---|
| Arithmetic | + - * / // % ** | 7 // 2 = 3, 7 % 2 = 1, 2 ** 3 = 8, 7 / 2 = 3.5 |
| Comparison | == != < > <= >= | 5 >= 3 → True |
| Logical | and, or, not | (x > 0) and (x < 10) |
| Membership | in, not in | "a" in "cat" → True |
| Identity | is, is not | x is None |
| Assignment | = += -= *= /= | x += 1 |
1.3 Strings
s = "Machine Learning"
s.lower() # 'machine learning'
s.upper() # 'MACHINE LEARNING'
s.split(" ") # ['Machine', 'Learning']
s.replace("Machine", "Deep")
s[0] # 'M' (indexing starts at 0)
s[0:7] # 'Machine' (slicing)
len(s) # 16
# f-strings: the modern way to format text
accuracy = 0.93456
print(f"Accuracy: {accuracy:.2%}") # Accuracy: 93.46%
print(f"Model: {s}, score={accuracy:.3f}")
1.4 Core Data Structures
| Structure | Syntax | Ordered? | Mutable? | Duplicates? | Use it for |
|---|---|---|---|---|---|
| List | [1, 2, 3] | ✅ | ✅ | ✅ | General-purpose sequences |
| Tuple | (1, 2, 3) | ✅ | ❌ | ✅ | Fixed data, e.g. a shape (100, 5) |
| Dictionary | {"a": 1} | ✅ (insertion order) | ✅ | Keys unique | Key → value lookups, hyperparameters |
| Set | {1, 2, 3} | ❌ | ✅ | ❌ | Unique items, fast membership checks |
# List
nums = [10, 20, 30]
nums.append(40) # [10, 20, 30, 40]
nums[-1] # 40 (negative index = from the end)
nums[1:3] # [20, 30]
# Tuple
shape = (100, 5)
rows, cols = shape # unpacking
# Dictionary
params = {"lr": 0.01, "epochs": 50}
params["batch_size"] = 32
params.get("momentum", 0.9) # safe lookup with default
for key, value in params.items():
print(key, value)
# Set
labels = {"cat", "dog", "cat"} # {'cat', 'dog'}
1.5 Control Flow
# if / elif / else
score = 82
if score >= 90:
grade = "A"
elif score >= 75:
grade = "B"
else:
grade = "C"
# for loop
for i in range(3): # 0, 1, 2
print(i)
for idx, label in enumerate(["cat", "dog"]):
print(idx, label) # 0 cat / 1 dog
for a, b in zip([1, 2], ["x", "y"]):
print(a, b)
# while loop
n = 0
while n < 3:
n += 1
# break / continue
for x in range(10):
if x == 2: continue # skip 2
if x == 5: break # stop at 5
⚠️ Python uses indentation (spaces) to define blocks. Be consistent. 4 spaces is the standard.
1.6 Comprehensions: Compact Loops
squares = [x**2 for x in range(5)] # [0, 1, 4, 9, 16]
evens = [x for x in range(10) if x % 2 == 0] # [0, 2, 4, 6, 8]
lookup = {w: len(w) for w in ["cat", "horse"]} # {'cat': 3, 'horse': 5}
unique = {x % 3 for x in range(10)} # {0, 1, 2}
1.7 Functions
A function is a reusable block of code. It keeps programs organised and avoids repetition (DRY: Don't Repeat Yourself).
def mean_squared_error(y_true, y_pred):
"""Return the average squared difference between two lists."""
total = 0
for t, p in zip(y_true, y_pred):
total += (t - p) ** 2
return total / len(y_true)
mean_squared_error([3, 5], [2, 7]) # 2.5
The text in triple quotes is a docstring, the function's built-in documentation (help(mean_squared_error) shows it).
Parameters and arguments
def train(model_name, epochs=10, lr=0.01): # epochs and lr have defaults
return f"{model_name}: epochs={epochs}, lr={lr}"
train("cnn") # uses defaults
train("cnn", 20) # positional arguments
train("cnn", lr=0.1, epochs=5) # keyword arguments (order doesn't matter)
*args and **kwargs: flexible numbers of arguments
def total(*args): # args is a tuple of all positional args
return sum(args)
total(1, 2, 3) # 6
def describe(**kwargs): # kwargs is a dict of all keyword args
for k, v in kwargs.items():
print(k, "=", v)
describe(lr=0.01, epochs=5)
Many ML libraries accept **kwargs to pass settings along.
Returning multiple values
def min_max(values):
return min(values), max(values) # actually returns a tuple
low, high = min_max([4, 9, 1])
Lambda (anonymous) functions
square = lambda x: x ** 2
square(4) # 16
pairs = [("a", 3), ("b", 1), ("c", 2)]
sorted(pairs, key=lambda p: p[1]) # sort by the second item
map and filter
list(map(lambda x: x * 2, [1, 2, 3])) # [2, 4, 6]
list(filter(lambda x: x > 1, [1, 2, 3])) # [2, 3]
(List comprehensions are usually more readable.)
Scope: where a variable lives
x = 10 # global
def f():
x = 5 # local; separate from the global x
return x
f() # 5
x # still 10
Variables created inside a function are local to it.
⚠️ Pitfall: mutable default arguments
def bad(item, bucket=[]): # the SAME list is reused on every call!
bucket.append(item)
return bucket
def good(item, bucket=None):
if bucket is None:
bucket = []
bucket.append(item)
return bucket
1.8 Modules and Imports
A module is a .py file of reusable code. A package is a folder of modules. Libraries are imported with import.
import math
import numpy as np # alias: np
from math import sqrt # import just one thing
from sklearn.model_selection import train_test_split
math.sqrt(16)
1.9 Error Handling
try:
value = int("abc")
except ValueError as e:
print("Could not convert:", e)
else:
print("Success") # runs only if no error occurred
finally:
print("Always runs") # cleanup code
Common errors: ValueError, TypeError, KeyError, IndexError, FileNotFoundError.
1.10 Reading and Writing Files
with open("notes.txt", "w") as f: # 'with' closes the file automatically
f.write("hello\n")
with open("notes.txt", "r") as f:
text = f.read()
1.11 Generators (a handy advanced idea)
A generator produces values one at a time instead of building a whole list in memory, which is useful for large data.
def batches(data, size):
for i in range(0, len(data), size):
yield data[i:i + size] # 'yield' pauses and resumes
for batch in batches(list(range(10)), 4):
print(batch) # [0,1,2,3] / [4,5,6,7] / [8,9]
🧠 Why ML cares
Data loading, feeding data in batches, writing helper functions for preprocessing, and organising experiments all rely on these basics.
2. Object-Oriented Programming Basics
Object-Oriented Programming (OOP) organises code into objects that bundle data (attributes) and behaviour (methods). ML libraries are built this way. A model is an object with methods like .fit() and .predict().
2.1 Classes and Objects
- Class: a blueprint (e.g., "Dog").
- Object (instance): a specific thing built from that blueprint (e.g., my dog Bruno).
class Student:
school = "ABC University" # class attribute (shared by all instances)
def __init__(self, name, marks): # constructor: runs when the object is created
self.name = name # instance attributes (unique to each object)
self.marks = marks
def is_passing(self): # method
return self.marks >= 40
def __str__(self): # what print() shows
return f"{self.name} ({self.marks})"
s1 = Student("Asha", 72)
s2 = Student("Ravi", 35)
s1.is_passing() # True
print(s2) # Ravi (35)
| Term | Meaning |
|---|---|
__init__ | Special method (constructor) that sets up a new object |
self | Refers to the current object. It's always the first parameter of a method |
| Attribute | A variable stored on an object (s1.name) |
| Method | A function defined inside a class (s1.is_passing()) |
| Instance attribute | Belongs to one object |
| Class attribute | Shared by all objects of the class |
2.2 The Four Pillars of OOP
1) Encapsulation: bundle data with methods, and control access
Python uses naming conventions:
name: public_name: "internal use" (a polite hint, not enforced)__name: name-mangled to make accidental access harder
class Account:
def __init__(self, balance):
self._balance = balance # internal
@property
def balance(self): # read-only access via a property
return self._balance
def deposit(self, amount):
if amount <= 0:
raise ValueError("Amount must be positive")
self._balance += amount
a = Account(100)
a.deposit(50)
a.balance # 150
2) Inheritance: a new class reuses and extends an existing one
class Animal:
def __init__(self, name):
self.name = name
def speak(self):
return "..."
class Dog(Animal): # Dog inherits from Animal
def speak(self): # overrides the parent method
return "Woof!"
class Cat(Animal):
def __init__(self, name, indoor):
super().__init__(name) # call the parent's constructor
self.indoor = indoor
def speak(self):
return "Meow!"
3) Polymorphism: the same method name, different behaviour
for pet in [Dog("Bruno"), Cat("Mia", True)]:
print(pet.name, pet.speak()) # each class responds its own way
This is why you can swap one ML model for another, since they all share .fit() and .predict().
4) Abstraction: expose what's needed, hide the details
You call model.fit(X, y) without knowing the math inside.
2.3 Special ("Dunder") Methods
Methods with double underscores customise how objects behave.
| Method | Purpose | Triggered by |
|---|---|---|
__init__ | Initialise object | Student(...) |
__str__ | Friendly text | print(obj) |
__repr__ | Developer-oriented text | Typing the object in a console |
__len__ | Length | len(obj) |
__getitem__ | Indexing | obj[0] |
__add__ | The + operator | a + b |
__call__ | Make an object callable | obj(...) |
2.4 Class Methods and Static Methods
class Dataset:
def __init__(self, rows):
self.rows = rows
@classmethod
def from_csv_text(cls, text): # alternative constructor; gets the class (cls)
return cls(text.split("\n"))
@staticmethod
def is_valid_row(row): # no self / cls; just a utility function
return len(row) > 0
2.5 OOP in ML: A Mini "Estimator"
Here's a toy model that follows the same pattern as Scikit-learn's estimators:
class MeanRegressor:
"""Predicts the average of the training targets. Simple, but shows the pattern."""
def fit(self, X, y):
self.mean_ = sum(y) / len(y) # learned attribute (trailing underscore = learned)
return self # returning self allows chaining
def predict(self, X):
return [self.mean_ for _ in X]
model = MeanRegressor()
model.fit([[1], [2], [3]], [10, 20, 30])
model.predict([[4], [5]]) # [20.0, 20.0]
🧠 Why ML cares
- Scikit-learn models, PyTorch/TensorFlow models and Pandas DataFrames are all objects.
- Writing your own reusable preprocessing steps or models means writing classes.
- Understanding
self, inheritance and methods makes library documentation far easier to read.
3. NumPy: Arrays and Vectorization
NumPy (Numerical Python) provides the ndarray, a fast, memory-efficient N-dimensional array. It's the foundation of almost the entire Python ML ecosystem (Pandas, SciPy and Scikit-learn all build on it).
import numpy as np
3.1 Why NumPy Instead of Python Lists?
| Python list | NumPy array | |
|---|---|---|
| Element types | Mixed allowed | One type (homogeneous) |
| Speed | Slow loops | Fast (compiled C under the hood) |
| Memory | Heavier | Compact |
| Math on whole collection | Needs loops | Built-in vectorized operations |
| Dimensions | Nested lists | True N-D arrays |
3.2 Creating Arrays
a = np.array([1, 2, 3]) # 1-D
M = np.array([[1, 2, 3], [4, 5, 6]]) # 2-D (2 rows, 3 columns)
np.zeros((2, 3)) # 2×3 of 0s
np.ones((2, 3)) # 2×3 of 1s
np.full((2, 2), 7) # filled with 7
np.eye(3) # 3×3 identity matrix
np.arange(0, 10, 2) # [0 2 4 6 8]
np.linspace(0, 1, 5) # 5 evenly spaced values from 0 to 1
rng = np.random.default_rng(42) # seeded generator = reproducible results
rng.random((2, 3)) # uniform random in [0, 1)
rng.normal(0, 1, size=5) # Gaussian samples
rng.integers(0, 10, size=4) # random ints
🔁 Always set a seed in ML experiments so results can be reproduced.
3.3 Array Attributes
M.shape # (2, 3) → rows, columns
M.ndim # 2 → number of dimensions
M.size # 6 → total elements
M.dtype # int64 → data type
M.T # transpose
Data types: int32, int64, float32, float64, bool. Convert with M.astype(np.float32). (float32 halves memory vs float64, which is common in deep learning.)
3.4 Indexing and Slicing
M = np.array([[10, 20, 30],
[40, 50, 60],
[70, 80, 90]])
M[0, 1] # 20 → row 0, column 1
M[1] # [40 50 60] → entire row 1
M[:, 2] # [30 60 90] → entire column 2
M[0:2, 1:3] # [[20 30], [50 60]] → sub-matrix
M[-1, -1] # 90
Format: array[row_slice, column_slice], with slices as start:stop:step.
Boolean masking: select by condition
x = np.array([5, -2, 8, -1, 3])
x > 0 # [ True False True False True]
x[x > 0] # [5 8 3]
x[x < 0] = 0 # replace negatives with 0 → [5 0 8 0 3]
Fancy indexing: select by a list of positions
x = np.array([10, 20, 30, 40])
x[[0, 2]] # [10 30]
⚠️ Slices are views, not copies. Changing a slice changes the original. Use
.copy()for an independent copy.b = a[:2].copy()
3.5 Reshaping and Combining
a = np.arange(12) # [0 1 2 ... 11]
a.reshape(3, 4) # 3 rows × 4 columns
a.reshape(2, -1) # -1 means "figure this dimension out" → 2×6
a.flatten() # back to 1-D
x = np.array([1, 2, 3])
x[:, np.newaxis].shape # (3, 1) → add a dimension (column vector)
np.concatenate([a1, a2]) # join along an existing axis
np.vstack([a1, a2]) # stack vertically (as rows)
np.hstack([a1, a2]) # stack horizontally (as columns)
np.stack([a1, a2]) # join along a NEW axis
3.6 Vectorization: The Big Idea ⚡
Vectorization means applying an operation to an entire array at once, with no explicit Python loop. NumPy runs the loop in fast compiled code.
x = np.array([1, 2, 3, 4])
# ❌ Slow, loop style
result = []
for v in x:
result.append(v * 2 + 1)
# ✅ Fast, vectorized style
result = x * 2 + 1 # [3 5 7 9]
Universal functions (ufuncs) apply element-wise:
np.sqrt(x) np.exp(x) np.log(x) np.sin(x) np.abs(x)
Vectorized math feels like the formulas:
# Mean Squared Error: (1/n) Σ (y − ŷ)²
mse = np.mean((y_true - y_pred) ** 2)
# Sigmoid activation
sigmoid = 1 / (1 + np.exp(-z))
Speed intuition: for a million numbers, the vectorized version is typically tens to hundreds of times faster than a Python loop.
3.7 Broadcasting: Math Between Different Shapes
Broadcasting lets NumPy combine arrays of different shapes by virtually "stretching" the smaller one, without copying data.
M = np.array([[1, 2, 3],
[4, 5, 6]]) # shape (2, 3)
row = np.array([10, 20, 30]) # shape (3,)
M + row
# [[11 22 33]
# [14 25 36]] ← the row was added to every row of M
The rules (compare shapes from the rightmost dimension backwards). Two dimensions are compatible if they are equal or one of them is 1. Missing dimensions are treated as 1.
(2, 3) + (3,) → ✅ (3,) acts like (1, 3) → stretched to (2, 3)
(3, 1) + (1, 4) → ✅ result (3, 4)
(2, 3) + (2,) → ❌ 3 vs 2 don't match
Practical example: standardising features
X = np.array([[1., 200.],
[2., 300.],
[3., 400.]])
X_scaled = (X - X.mean(axis=0)) / X.std(axis=0)
# means and stds have shape (2,), broadcast across every row of X
3.8 Aggregations and the axis Argument
M = np.array([[1, 2, 3],
[4, 5, 6]])
M.sum() # 21 → all elements
M.sum(axis=0) # [5 7 9] → collapse ROWS → one value per COLUMN
M.sum(axis=1) # [6 15] → collapse COLUMNS → one value per ROW
M.mean(), M.std(), M.min(), M.max(), M.argmax(), np.median(M)
🧠 Remember:
axis=0goes down (across rows),axis=1goes across (across columns). The axis you name is the one that disappears.
3.9 Linear Algebra with NumPy
A = np.array([[1, 2], [3, 4]])
B = np.array([[5, 6], [7, 8]])
v = np.array([1, 2])
A * B # element-wise multiplication
A @ B # matrix multiplication → [[19 22], [43 50]]
np.dot(v, v) # dot product → 5
A.T # transpose
np.linalg.inv(A) # inverse
np.linalg.det(A) # determinant → -2.0
np.linalg.matrix_rank(A) # rank
vals, vecs = np.linalg.eig(A) # eigenvalues and eigenvectors
U, S, Vt = np.linalg.svd(A) # singular value decomposition
np.linalg.norm(v) # vector length
np.linalg.solve(A, b) # solve A x = b (preferred over inv(A) @ b)
3.10 Handy Extras
np.where(x > 0, "pos", "neg") # vectorized if-else
np.unique(labels, return_counts=True)
np.sort(x), np.argsort(x) # sorted values / sorted positions
np.clip(x, 0, 1) # limit values to a range
np.isnan(x) # find missing values (NaN)
np.cumsum(x) # running total
🧠 Why ML cares
Datasets become NumPy arrays X with shape (n_samples, n_features). Predictions, weights, gradients and loss calculations are all vectorized array operations. Writing loops over samples is almost always the wrong choice.
4. Pandas: Series and DataFrames
Pandas is the go-to library for tabular data (think Excel spreadsheet or SQL table) with labelled rows and columns.
import pandas as pd
4.1 Series: A Labelled 1-D Array
A Series is a single column of data with an index (labels).
s = pd.Series([250, 300, 150], index=["Mon", "Tue", "Wed"], name="sales")
s["Tue"] # 300 → by label
s.iloc[0] # 250 → by position
s[s > 200] # Mon, Tue → boolean filter
s * 2 # vectorized, like NumPy
s.mean(), s.max(), s.describe()
4.2 DataFrame: A 2-D Table
A DataFrame is a table of columns, where each column is a Series (and each can have a different type).
df = pd.DataFrame({
"name": ["Asha", "Ravi", "Meera", "Karan"],
"age": [25, 31, 28, 40],
"city": ["Bhopal", "Delhi", "Bhopal", "Pune"],
"salary": [50000, 72000, 61000, 90000],
})
name age city salary
0 Asha 25 Bhopal 50000
1 Ravi 31 Delhi 72000
2 Meera 28 Bhopal 61000
3 Karan 40 Pune 90000
Parts of a DataFrame: df.index (row labels), df.columns (column names), df.values (the underlying NumPy array), df.shape, df.dtypes.
4.3 Reading and Writing Data
df = pd.read_csv("data.csv") # most common
df = pd.read_excel("data.xlsx")
df = pd.read_json("data.json")
df = pd.read_csv("data.csv", sep=";", usecols=["age", "city"], nrows=1000)
df.to_csv("clean.csv", index=False) # index=False avoids an extra index column
4.4 First Look at Your Data
df.head() # first 5 rows
df.tail(3) # last 3 rows
df.shape # (rows, columns)
df.info() # column names, types, non-null counts, memory use
df.describe() # count, mean, std, min, quartiles, max for numeric columns
df.describe(include="object") # summary of text columns
df.columns # column names
df["city"].value_counts() # frequency of each category
df["city"].unique() # distinct values
df.nunique() # number of unique values per column
4.5 Selecting Data
df["age"] # one column → Series
df[["name", "age"]] # several columns → DataFrame
# By LABEL with .loc[rows, columns]
df.loc[0, "name"] # 'Asha'
df.loc[0:2, ["name", "age"]] # label slices INCLUDE the end (rows 0,1,2)
# By POSITION with .iloc[rows, columns]
df.iloc[0, 1] # row 0, column 1 → 25
df.iloc[0:2, 0:2] # position slices EXCLUDE the end
# Boolean filtering
df[df["age"] > 28]
df[(df["age"] > 28) & (df["city"] == "Bhopal")] # AND → use &, wrap in ()
df[(df["city"] == "Pune") | (df["age"] < 26)] # OR → use |
df[df["city"].isin(["Delhi", "Pune"])]
df.query("age > 28 and city == 'Bhopal'") # readable alternative
.loc | .iloc | |
|---|---|---|
| Selects by | Labels | Integer positions |
| Slice end | Inclusive | Exclusive |
| Example | df.loc[1, "age"] | df.iloc[1, 1] |
⚠️ Use
&,|,~for combining conditions, notand,or,not. And always wrap each condition in parentheses.
4.6 Adding, Changing and Removing Columns
df["salary_k"] = df["salary"] / 1000 # new column from existing ones
df["senior"] = df["age"] >= 30 # boolean column
df["grade"] = np.where(df["salary"] > 60000, "high", "low")
df = df.rename(columns={"salary": "income"})
df = df.drop(columns=["salary_k"]) # remove a column
df = df.drop(index=[0]) # remove a row by label
4.7 Sorting
df.sort_values("age") # ascending
df.sort_values("salary", ascending=False)
df.sort_values(["city", "age"], ascending=[True, False])
df.sort_index()
4.8 Aggregation and GroupBy
GroupBy follows split → apply → combine: split rows into groups, compute something per group, combine the results.
df["salary"].mean() # one number
df.groupby("city")["salary"].mean() # average salary per city
df.groupby("city").agg(
avg_salary=("salary", "mean"),
max_age=("age", "max"),
headcount=("name", "count"),
)
4.9 Combining DataFrames
# Stack vertically / side by side
pd.concat([df1, df2], axis=0, ignore_index=True) # add rows
pd.concat([df1, df2], axis=1) # add columns
# Join on a key column (like SQL JOIN)
pd.merge(customers, orders, on="customer_id", how="left")
how= | Keeps |
|---|---|
inner | Only keys present in both tables |
left | All rows from the left, matches from the right |
right | All rows from the right, matches from the left |
outer | All rows from both |
4.10 apply: Custom Functions
df["age_group"] = df["age"].apply(lambda a: "young" if a < 30 else "adult")
df["name_length"] = df["name"].apply(len)
⚡ Built-in vectorized operations (
df["age"] * 2,np.where,.strmethods) are faster thanapply. Useapplyonly when there's no vectorized alternative.
🧠 Why ML cares
Real datasets arrive as CSV files or database tables. Pandas is how you load, inspect, filter, join and reshape that data into the clean feature table that ML models expect.
5. Data Cleaning and Manipulation
"Garbage in, garbage out." Data scientists commonly spend the majority of their time cleaning data. Better data usually beats a fancier model.
5.1 A Typical Cleaning Checklist
1. Inspect → shape, dtypes, head, describe, info
2. Fix types → numbers stored as text, dates as strings
3. Missing values → find, then drop or fill
4. Duplicates → find and remove
5. Inconsistent → typos, spacing, mixed capitalisation, units
6. Outliers → detect, then decide what to do
7. Encode & scale → turn data into model-ready numbers
8. Validate → re-check everything
5.2 Handling Missing Values
Missing data appears as NaN (Not a Number) or None.
df.isna().sum() # count of missing values per column
df.isna().mean() * 100 # percentage missing per column
df[df["age"].isna()] # rows where age is missing
Options
| Strategy | Code | Good when |
|---|---|---|
| Drop rows | df.dropna() | Few rows are missing and data is plentiful |
| Drop columns | df.drop(columns=["col"]) | A column is mostly empty |
| Fill with a constant | df["x"].fillna(0) | A sensible default exists (e.g., 0 for "no purchases") |
| Fill with mean / median | df["age"].fillna(df["age"].median()) | Numeric columns (median is safer with outliers) |
| Fill with mode | df["city"].fillna(df["city"].mode()[0]) | Categorical columns |
| Forward / backward fill | df["x"].ffill(), df["x"].bfill() | Time-ordered data |
| Interpolate | df["x"].interpolate() | Smoothly varying numeric series |
df = df.dropna(subset=["salary"]) # drop rows missing salary only
df["age"] = df["age"].fillna(df["age"].median())
df = df.dropna(thresh=3) # keep rows with at least 3 non-null values
💡 Always ask why data is missing. Sometimes the missingness itself carries information. (A common trick is adding an
is_missingindicator column before filling.)
5.3 Removing Duplicates
df.duplicated().sum() # how many duplicate rows
df = df.drop_duplicates()
df = df.drop_duplicates(subset=["email"], keep="first")
5.4 Fixing Data Types
df["age"] = pd.to_numeric(df["age"], errors="coerce") # bad values → NaN
df["join_date"] = pd.to_datetime(df["join_date"])
df["city"] = df["city"].astype("category") # saves memory
df["price"] = df["price"].astype(float)
5.5 Cleaning Text Columns
df["city"] = df["city"].str.strip() # remove extra spaces
df["city"] = df["city"].str.lower() # consistent case
df["city"] = df["city"].str.replace("-", " ")
df["name"].str.contains("As") # boolean mask
df["email"].str.split("@").str[1] # extract the domain
df["price"] = df["price"].str.replace("$", "", regex=False).astype(float)
df["city"] = df["city"].replace({"bhopal ": "bhopal", "delhi": "new delhi"})
5.6 Working with Dates
df["join_date"] = pd.to_datetime(df["join_date"])
df["year"] = df["join_date"].dt.year
df["month"] = df["join_date"].dt.month
df["weekday"] = df["join_date"].dt.day_name()
df["days_since"] = (pd.Timestamp("2026-01-01") - df["join_date"]).dt.days
Splitting a date into parts (year, month, weekday) creates useful features.
5.7 Detecting and Handling Outliers
Outliers are unusual values far from the rest. They can be errors or genuine extremes.
The IQR rule
Q1 = df["salary"].quantile(0.25)
Q3 = df["salary"].quantile(0.75)
IQR = Q3 - Q1
lower, upper = Q1 - 1.5 * IQR, Q3 + 1.5 * IQR
outliers = df[(df["salary"] < lower) | (df["salary"] > upper)]
The z-score rule
z = (df["salary"] - df["salary"].mean()) / df["salary"].std()
df[z.abs() > 3] # more than 3 standard deviations from the mean
What to do with them
- Remove them if they are clear errors
- Cap / winsorize:
df["salary"].clip(lower, upper) - Transform (e.g., log) to reduce skew:
np.log1p(df["salary"]) - Keep them if they're real and meaningful (e.g., fraud cases)
5.8 Encoding Categorical Variables
Models need numbers, so categories must be converted.
# One-hot encoding: one 0/1 column per category (for categories with no natural order)
pd.get_dummies(df, columns=["city"], drop_first=True)
# Label / ordinal encoding: integers for categories WITH a natural order
order = {"low": 0, "medium": 1, "high": 2}
df["level_num"] = df["level"].map(order)
| Encoding | Use when | Watch out |
|---|---|---|
| One-hot | Categories have no order (city, colour) | Many categories → many columns |
| Ordinal | Categories have a meaningful order (low/med/high) | Don't use for unordered categories, since the model would assume false distances |
drop_first=True removes one redundant column to avoid perfect redundancy between the dummy columns.
5.9 Scaling and Normalising Numeric Columns
Features on very different scales (age: 20–60, salary: 20,000–200,000) can hurt many algorithms.
# Min-max scaling → range [0, 1]
df["age_mm"] = (df["age"] - df["age"].min()) / (df["age"].max() - df["age"].min())
# Standardisation (z-score) → mean 0, std 1
df["age_z"] = (df["age"] - df["age"].mean()) / df["age"].std()
(Scikit-learn offers MinMaxScaler and StandardScaler that do this properly. See Section 8.)
5.10 Reshaping and Summarising
# Binning a numeric column into ranges
df["age_band"] = pd.cut(df["age"], bins=[0, 25, 35, 100], labels=["<25", "25-35", "35+"])
pd.qcut(df["salary"], q=4) # equal-sized groups (quartiles)
# Pivot table: Excel-style summary
pd.pivot_table(df, values="salary", index="city", columns="senior", aggfunc="mean")
# Crosstab: counts of category combinations
pd.crosstab(df["city"], df["senior"])
# Wide ↔ long format
pd.melt(df, id_vars=["name"], value_vars=["age", "salary"])
# Correlations
df.corr(numeric_only=True)
5.11 Method Chaining: Readable Pipelines
clean = (
pd.read_csv("raw.csv")
.drop_duplicates()
.rename(columns=str.lower)
.assign(age=lambda d: d["age"].fillna(d["age"].median()))
.query("salary > 0")
.reset_index(drop=True)
)
⚠️ Avoid chained assignment like
df[df["age"] > 30]["salary"] = 0. It may silently fail or change a copy. Usedf.loc[df["age"] > 30, "salary"] = 0instead. Usedf.copy()when you need an independent copy.
🧠 Why ML cares
Clean, consistent, numeric data is a prerequisite for training. Mistakes made here (such as leaking information or mishandling missing values) quietly damage every later step.
6. Matplotlib and Seaborn Visualization
"A picture is worth a thousand rows." Visualization helps you understand data, spot problems, and communicate results.
import matplotlib.pyplot as plt
import seaborn as sns
| Matplotlib | Seaborn | |
|---|---|---|
| Role | The foundation: full control over every detail | A high-level layer on top of Matplotlib |
| Strength | Highly customisable | Beautiful statistical plots in one line |
| Works with | Arrays, lists, DataFrames | DataFrames (especially "tidy" ones) |
| Typical use | Custom figures, fine-tuning | Quick exploration, distributions, relationships |
They work together. Seaborn draws, Matplotlib fine-tunes.
6.1 Matplotlib Basics
Anatomy of a figure
Figure → the whole canvas (the "window" or image)
└─ Axes → one individual plot (with x-axis, y-axis, title, legend, ...)
The simple way
x = [1, 2, 3, 4, 5]
y = [2, 4, 5, 4, 6]
plt.plot(x, y)
plt.title("Simple Line")
plt.xlabel("x")
plt.ylabel("y")
plt.show()
The recommended way: object-oriented (fig, ax)
fig, ax = plt.subplots(figsize=(6, 4))
ax.plot(x, y, color="tab:blue", linestyle="--", marker="o", label="trend")
ax.set_title("Simple Line")
ax.set_xlabel("x")
ax.set_ylabel("y")
ax.legend()
ax.grid(True, alpha=0.3)
fig.tight_layout()
fig.savefig("plot.png", dpi=150)
plt.show()
6.2 Common Matplotlib Plot Types
fig, axes = plt.subplots(2, 2, figsize=(10, 8)) # a 2×2 grid of plots
axes[0, 0].plot(x, y) # Line plot (trends over time)
axes[0, 0].set_title("Line")
axes[0, 1].scatter(df["age"], df["salary"]) # Scatter (relationship of 2 variables)
axes[0, 1].set_title("Scatter")
axes[1, 0].bar(["A", "B", "C"], [10, 24, 17]) # Bar (compare categories)
axes[1, 0].set_title("Bar")
axes[1, 1].hist(df["age"], bins=10, edgecolor="black") # Histogram (distribution)
axes[1, 1].set_title("Histogram")
fig.tight_layout()
plt.show()
| Plot | Function | Best for |
|---|---|---|
| Line | ax.plot | Trends over time, training loss curves |
| Scatter | ax.scatter | Relationship between two numeric variables |
| Bar | ax.bar / ax.barh | Comparing categories |
| Histogram | ax.hist | Distribution of one numeric variable |
| Box plot | ax.boxplot | Spread, median and outliers |
| Pie | ax.pie | Parts of a whole (use sparingly) |
| Image | ax.imshow | Images, matrices, heatmaps |
Useful customisations
ax.set_xlim(a, b), ax.set_xticks(...), ax.tick_params(axis="x", rotation=45), ax.axhline(0) (reference line), ax.annotate(...), plt.style.use("seaborn-v0_8").
6.3 Seaborn Basics
Seaborn works directly with DataFrames, using column names.
sns.set_theme(style="whitegrid") # nicer default look
Distribution plots (one variable)
sns.histplot(data=df, x="age", bins=15, kde=True) # histogram + smooth density curve
sns.kdeplot(data=df, x="salary", fill=True) # smoothed density
sns.boxplot(data=df, x="city", y="salary") # median, quartiles, outliers per group
sns.violinplot(data=df, x="city", y="salary") # box plot + distribution shape
Relationship plots (two variables)
sns.scatterplot(data=df, x="age", y="salary", hue="city", size="age")
sns.lineplot(data=df, x="year", y="sales")
sns.regplot(data=df, x="age", y="salary") # scatter + fitted line
hue colours points by a category, so you can show a third variable.
Categorical plots
sns.countplot(data=df, x="city") # count of each category
sns.barplot(data=df, x="city", y="salary") # mean per category (with error bars)
Correlation heatmap: a must-do in ML exploration
corr = df.corr(numeric_only=True)
sns.heatmap(corr, annot=True, cmap="coolwarm", vmin=-1, vmax=1, fmt=".2f")
plt.title("Feature Correlations")
plt.show()
Shows at a glance which features move together and which relate to the target.
Pairwise relationships
sns.pairplot(df, hue="city") # grid of scatter plots + distributions
Subplots with Seaborn
fig, axes = plt.subplots(1, 2, figsize=(10, 4))
sns.histplot(df["age"], ax=axes[0]) # pass ax= to place a plot in a subplot
sns.boxplot(x=df["salary"], ax=axes[1])
plt.show()
6.4 Which Chart Should I Use?
| Question | Chart |
|---|---|
| How is one numeric variable distributed? | Histogram, KDE, box plot |
| How do two numeric variables relate? | Scatter plot (+ regression line) |
| How do many numeric variables relate? | Correlation heatmap, pair plot |
| How do values compare across categories? | Bar plot, box plot |
| How often does each category occur? | Count plot |
| How does something change over time? | Line plot |
| Spotting outliers? | Box plot, scatter plot |
6.5 ML-Specific Plot Examples
# Training curves
plt.plot(train_losses, label="train")
plt.plot(val_losses, label="validation")
plt.xlabel("epoch"); plt.ylabel("loss"); plt.legend(); plt.show()
# Class balance of the target
sns.countplot(x=y)
# Confusion matrix as a heatmap
sns.heatmap(cm, annot=True, fmt="d", cmap="Blues")
6.6 Good Visualization Habits
- ✅ Always label axes and add a title (include units)
- ✅ Choose the chart type that matches the question
- ✅ Keep it simple: avoid clutter and 3-D effects
- ✅ Use colour meaningfully (and consider colour-blind-friendly palettes)
- ✅ Start bar charts at zero
- ❌ Don't use pie charts for many categories
🧠 Why ML cares
Plots reveal skewed distributions, outliers, class imbalance, correlated features and data errors before modelling, and show how training is going after it.
7. SciPy for Scientific Computing
SciPy builds on NumPy and adds a large collection of scientific and numerical algorithms. Where NumPy gives you arrays, SciPy gives you ready-made tools to do science with them.
import scipy
from scipy import stats, optimize, linalg, integrate, sparse, interpolate, spatial
7.1 What's Inside
| Submodule | What it provides |
|---|---|
scipy.stats | Probability distributions, statistical tests, descriptive statistics |
scipy.optimize | Minimisation, curve fitting, root finding |
scipy.linalg | Linear algebra routines (more complete than numpy.linalg) |
scipy.sparse | Memory-efficient sparse matrices |
scipy.integrate | Numerical integration and differential equations |
scipy.interpolate | Interpolation (estimating values between known points) |
scipy.spatial | Distances, nearest neighbours, spatial structures |
scipy.special | Special functions (e.g., sigmoid via expit, gamma) |
scipy.signal, scipy.fft | Signal processing and Fourier transforms |
7.2 scipy.stats: Distributions and Statistical Tests
Working with distributions
from scipy import stats
norm = stats.norm(loc=0, scale=1) # standard normal: mean 0, std 1
norm.pdf(0) # density at 0 → ≈ 0.3989
norm.cdf(1.96) # P(X ≤ 1.96) → ≈ 0.975
norm.ppf(0.975) # inverse CDF → ≈ 1.96 (the "critical value")
norm.rvs(size=5, random_state=42) # draw random samples
| Method | Meaning |
|---|---|
pdf / pmf | Density (continuous) / probability (discrete) at a value |
cdf | Cumulative probability P(X ≤ x) |
ppf | Percent-point function (inverse of CDF) |
sf | Survival function = 1 − CDF |
rvs | Random variates (samples) |
Other distributions: stats.binom, stats.poisson, stats.uniform, stats.expon, stats.t, stats.chi2.
Statistical tests
a = [23, 25, 28, 30, 31]
b = [30, 33, 35, 38, 40]
t_stat, p_value = stats.ttest_ind(a, b) # compare two group means
# small p-value (< 0.05) → evidence that the means differ
stats.ttest_1samp(a, popmean=25) # compare one sample to a known mean
stats.chi2_contingency(table) # relationship between categorical variables
stats.shapiro(a) # test for normality
stats.f_oneway(g1, g2, g3) # ANOVA across 3+ groups
Correlation and descriptive statistics
r, p = stats.pearsonr(x, y) # linear correlation + p-value
rho, p = stats.spearmanr(x, y) # rank (monotonic) correlation
stats.describe(a) # count, min/max, mean, variance, skew, kurtosis
stats.zscore(a) # standardise values
stats.skew(a), stats.kurtosis(a)
7.3 scipy.optimize: Finding Minima and Fitting Curves
Minimising a function
from scipy.optimize import minimize
def f(p): # f(x, y) = (x − 3)² + (y + 1)²
x, y = p
return (x - 3) ** 2 + (y + 1) ** 2
result = minimize(f, x0=[0, 0]) # x0 = starting guess
result.x # ≈ [ 3., -1.] ← the minimiser
result.fun # ≈ 0 ← the minimum value
result.success # True if it converged
minimize can also use constraints and bounds, and you can pick a method (method="BFGS", "L-BFGS-B", "SLSQP").
Curve fitting
from scipy.optimize import curve_fit
import numpy as np
def model(x, a, b):
return a * np.exp(b * x)
params, covariance = curve_fit(model, x_data, y_data)
a_fit, b_fit = params
Root finding
from scipy.optimize import root_scalar
root_scalar(lambda x: x**2 - 4, bracket=[0, 5]).root # 2.0
7.4 scipy.linalg
from scipy import linalg
A = np.array([[4., 1.], [2., 3.]])
linalg.inv(A)
linalg.det(A)
linalg.eig(A)
linalg.svd(A)
linalg.solve(A, np.array([1., 2.]))
(Includes many extra decompositions such as LU, QR and Cholesky.)
7.5 scipy.sparse: Matrices Full of Zeros
Many ML datasets (e.g., text with thousands of word-columns) are mostly zeros. A sparse matrix stores only the non-zero entries, saving huge amounts of memory.
from scipy import sparse
dense = np.array([[0, 0, 3],
[4, 0, 0],
[0, 0, 0]])
sp = sparse.csr_matrix(dense) # Compressed Sparse Row format
sp.nnz # 2 → number of stored (non-zero) values
sp.toarray() # convert back to a normal array
| Format | Good for |
|---|---|
csr_matrix | Fast row slicing and matrix-vector products |
csc_matrix | Fast column slicing |
coo_matrix | Easy construction from (row, col, value) triplets |
Scikit-learn accepts sparse matrices directly in many places.
7.6 scipy.integrate
from scipy.integrate import quad
value, error = quad(lambda x: x**2, 0, 1) # ∫₀¹ x² dx
value # ≈ 0.3333
Also includes solve_ivp for differential equations.
7.7 scipy.interpolate
from scipy.interpolate import interp1d
x = [0, 1, 2, 3]
y = [0, 1, 4, 9]
f = interp1d(x, y, kind="linear")
f(1.5) # estimated value at 1.5 → 2.5
7.8 scipy.spatial: Distances
from scipy.spatial.distance import cdist, euclidean, cosine
euclidean([0, 0], [3, 4]) # 5.0
cosine([1, 0], [0, 1]) # 1.0 (cosine DISTANCE = 1 − similarity)
A = np.array([[0, 0], [1, 1]])
B = np.array([[3, 4], [0, 1]])
cdist(A, B, metric="euclidean") # all pairwise distances (2×2 matrix)
Distance calculations are central to similarity-based methods.
NumPy vs SciPy at a glance
| NumPy | SciPy | |
|---|---|---|
| Core | Arrays and basic math | Algorithms built on arrays |
| Think of it as | The foundation | The toolbox |
| Examples | np.mean, np.dot | stats.ttest_ind, optimize.minimize |
🧠 Why ML cares
Statistical testing, distribution fitting, numerical optimisation, sparse data handling and distance calculations are everyday ML needs, and SciPy provides all of them (and Scikit-learn uses SciPy internally).
8. Scikit-learn Fundamentals
Scikit-learn (sklearn) is the most widely used library for classical machine learning. It offers a clean, consistent interface for dozens of algorithms plus all the tools around them: preprocessing, splitting data, evaluation and tuning.
pip install scikit-learn # install name
import sklearn # import name
8.1 The Consistent API: the Heart of Scikit-learn
Almost everything in Scikit-learn is an estimator with the same few methods. Learn the pattern once and you can use any model.
| Object type | Main methods | Purpose | Examples |
|---|---|---|---|
| Estimator | fit(X, y) | Learn from data | Every model and transformer |
| Predictor (model) | predict(X), score(X, y) | Make predictions | LinearRegression, LogisticRegression, RandomForestClassifier |
| Transformer | transform(X), fit_transform(X) | Modify data | StandardScaler, SimpleImputer, OneHotEncoder |
model = SomeModel(hyperparameter=value) # 1. create (set hyperparameters)
model.fit(X_train, y_train) # 2. learn from training data
predictions = model.predict(X_test) # 3. predict on new data
Conventions to remember
X= feature matrix with shape(n_samples, n_features)y= target vector with shape(n_samples,)- Hyperparameters are set in the constructor; learned values end in an underscore (e.g.,
model.coef_,model.intercept_)
8.2 Datasets
from sklearn.datasets import load_iris, make_classification
X, y = load_iris(return_X_y=True) # small built-in practice dataset
X.shape # (150, 4)
X, y = make_classification(n_samples=500, n_features=10, random_state=42) # synthetic data
8.3 Splitting the Data
from sklearn.model_selection import train_test_split
X_train, X_test, y_train, y_test = train_test_split(
X, y,
test_size=0.2, # 20% for testing
random_state=42, # reproducible split
stratify=y, # keep class proportions equal (classification)
)
(Why split? To test on data the model has never seen. See the ML Fundamentals notes on generalisation.)
8.4 A Complete First Example
from sklearn.datasets import load_iris
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import accuracy_score
X, y = load_iris(return_X_y=True)
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, random_state=42, stratify=y
)
model = LogisticRegression(max_iter=200)
model.fit(X_train, y_train)
y_pred = model.predict(X_test)
print("Accuracy:", accuracy_score(y_test, y_pred))
Swap LogisticRegression for another classifier and the other lines stay the same. That's the power of the consistent API.
8.5 Preprocessing with Transformers
from sklearn.preprocessing import StandardScaler, MinMaxScaler, OneHotEncoder, LabelEncoder
from sklearn.impute import SimpleImputer
| Transformer | What it does |
|---|---|
StandardScaler | Rescale each feature to mean 0, std 1 |
MinMaxScaler | Rescale each feature to the range [0, 1] |
SimpleImputer | Fill missing values (mean, median, most frequent, constant) |
OneHotEncoder | One-hot encode categorical features |
OrdinalEncoder | Integer-encode ordered categories |
LabelEncoder | Encode the target labels as integers |
scaler = StandardScaler()
X_train_scaled = scaler.fit_transform(X_train) # LEARN mean/std from training data, then apply
X_test_scaled = scaler.transform(X_test) # APPLY the same values to test data (no fit!)
🚨 Data leakage warning: call
fit/fit_transformon the training data only. Applyingtransformto test data with the training statistics keeps test information out of training. Fitting a scaler on the full dataset before splitting is a classic mistake.
imputer = SimpleImputer(strategy="median") # also "mean", "most_frequent", "constant"
X_train_filled = imputer.fit_transform(X_train)
8.6 Pipelines: Chain Steps Safely
A Pipeline bundles preprocessing and a model into one object. It prevents data leakage and keeps code tidy.
from sklearn.pipeline import Pipeline
pipe = Pipeline([
("imputer", SimpleImputer(strategy="median")),
("scaler", StandardScaler()),
("model", LogisticRegression(max_iter=200)),
])
pipe.fit(X_train, y_train) # runs every step in order on the training data
pipe.predict(X_test) # applies the same steps, then predicts
pipe.score(X_test, y_test)
Different columns, different treatment: ColumnTransformer
from sklearn.compose import ColumnTransformer
numeric_cols = ["age", "salary"]
categorical_cols = ["city"]
preprocess = ColumnTransformer([
("num", Pipeline([
("impute", SimpleImputer(strategy="median")),
("scale", StandardScaler()),
]), numeric_cols),
("cat", OneHotEncoder(handle_unknown="ignore"), categorical_cols),
])
full_pipe = Pipeline([
("prep", preprocess),
("model", LogisticRegression(max_iter=500)),
])
full_pipe.fit(df_train[numeric_cols + categorical_cols], y_train)
This works directly with Pandas DataFrames, with each column group processed appropriately.
8.7 Model Evaluation
from sklearn.metrics import (
accuracy_score, precision_score, recall_score, f1_score,
confusion_matrix, classification_report,
mean_absolute_error, mean_squared_error, r2_score,
)
Classification
accuracy_score(y_test, y_pred)
confusion_matrix(y_test, y_pred)
print(classification_report(y_test, y_pred)) # precision, recall, F1 for every class
Regression
mean_absolute_error(y_test, y_pred) # MAE
mean_squared_error(y_test, y_pred) # MSE
mean_squared_error(y_test, y_pred) ** 0.5 # RMSE
r2_score(y_test, y_pred) # R²: 1.0 is perfect
| Task | Common metrics |
|---|---|
| Classification | Accuracy, Precision, Recall, F1, ROC-AUC, Confusion matrix |
| Regression | MAE, MSE / RMSE, R² |
8.8 Cross-Validation: A More Reliable Estimate
A single train/test split can be lucky or unlucky. k-fold cross-validation splits the training data into k parts, trains on k − 1 of them and validates on the remaining one, rotating until every part has been the validation set.
Fold 1: [VAL][train][train][train][train]
Fold 2: [train][VAL][train][train][train]
Fold 3: [train][train][VAL][train][train]
...and so on, then average the k scores.
from sklearn.model_selection import cross_val_score
scores = cross_val_score(pipe, X_train, y_train, cv=5, scoring="accuracy")
print(scores.mean(), scores.std()) # average performance and its variability
Others: StratifiedKFold (keeps class balance in each fold), cross_validate (several metrics at once).
8.9 Hyperparameter Tuning
Hyperparameters are settings you choose before training (e.g., regularisation strength). Searching over them systematically:
from sklearn.model_selection import GridSearchCV, RandomizedSearchCV
param_grid = {"model__C": [0.01, 0.1, 1, 10]} # 'stepname__param' for pipelines
grid = GridSearchCV(pipe, param_grid, cv=5, scoring="accuracy")
grid.fit(X_train, y_train)
grid.best_params_ # best settings found
grid.best_score_ # best cross-validated score
best_model = grid.best_estimator_ # refit on all training data with the best settings
best_model.score(X_test, y_test) # final check on the untouched test set
| Method | How it works |
|---|---|
GridSearchCV | Tries every combination (thorough, can be slow) |
RandomizedSearchCV | Tries random combinations (faster for big search spaces) |
8.10 A Tour of Model Families Available
Just recognising where to find things (the details belong to later topics):
| Module | Examples |
|---|---|
sklearn.linear_model | LinearRegression, LogisticRegression, Ridge, Lasso |
sklearn.neighbors | KNeighborsClassifier |
sklearn.tree | DecisionTreeClassifier, DecisionTreeRegressor |
sklearn.ensemble | RandomForestClassifier, GradientBoostingClassifier |
sklearn.svm | SVC, SVR |
sklearn.naive_bayes | GaussianNB |
sklearn.cluster | KMeans, DBSCAN |
sklearn.decomposition | PCA |
8.11 Saving and Loading Models
import joblib
joblib.dump(best_model, "model.joblib") # save
loaded = joblib.load("model.joblib") # load later
loaded.predict(X_new)
Save the whole pipeline so preprocessing is applied identically in future.
8.12 Handy Tips
- Always set
random_statefor reproducibility. - Check
model.get_params()to see all hyperparameters. - Use
help(Model)or the official docs to see what each parameter means. - Start with a simple baseline model before trying complex ones.
🧠 Why ML cares
Scikit-learn turns the whole workflow: split → preprocess → train → evaluate → tune → save, into a few readable lines with a consistent interface.
9. Jupyter Notebook and Google Colab
9.1 What Are They?
Both let you write code, results, text, equations and charts in one document, which makes them ideal for experimentation, learning and sharing.
| Jupyter Notebook | Google Colab | |
|---|---|---|
| Runs | On your own computer (or a server) | In the cloud (Google's servers) |
| Setup | Install Python + Jupyter | None: just open in the browser with a Google account |
| Cost | Free | Free tier (paid plans exist for more resources) |
| Hardware | Your machine's CPU/GPU | Free access to GPU/TPU (limited and not guaranteed) |
| Packages | You install and manage them | Many ML libraries pre-installed |
| Files | Local file system | Temporary cloud storage; mount Google Drive to persist files |
| Sessions | Run as long as you like | Sessions time out after inactivity or a maximum duration |
| Sharing | Share the .ipynb file | Share a link, like Google Docs |
| Offline | ✅ Works offline | ❌ Needs internet |
Both use the .ipynb file format (IPython Notebook).
9.2 Starting Jupyter Notebook
pip install notebook # or: pip install jupyterlab
jupyter notebook # opens in your browser
# or
jupyter lab # the newer, more full-featured interface
In VS Code, you can also open .ipynb files directly.
9.3 Core Concepts
Cells: the building blocks
| Cell type | Contains | Run result |
|---|---|---|
| Code | Python code | Output shown beneath the cell |
| Markdown | Formatted text (headings, lists, links, LaTeX equations like $E = mc^2$) | Rendered text |
| Raw | Unformatted text | Not executed |
The kernel
The kernel is the Python engine running behind the notebook. It remembers variables between cells.
- Cells can be run in any order, which is powerful but can cause confusion. If you run cells out of order, you may see results that don't match the code as written top to bottom.
- Restart the kernel to clear all variables and start fresh.
- "Restart & Run All" is the best way to confirm your notebook works from top to bottom.
Cell [1]: x = 10
Cell [2]: y = x * 2 ← depends on x existing in the kernel's memory
Cell [3]: print(y) ← prints 20
Output of the last line
The last expression in a cell is displayed automatically, with no print needed:
df.head() # shows a neatly formatted table
9.4 Essential Keyboard Shortcuts (Jupyter)
Two modes: Edit mode (typing in a cell, green border) and Command mode (managing cells, blue border). Press Esc for command mode and Enter for edit mode.
| Shortcut | Action |
|---|---|
Shift + Enter | Run cell and move to the next |
Ctrl + Enter | Run cell and stay |
Esc / Enter | Command mode / Edit mode |
A / B (command mode) | Insert cell Above / Below |
D, D (press D twice) | Delete cell |
M / Y | Change cell to Markdown / code (Y) |
Z | Undo cell deletion |
Tab | Autocomplete |
Shift + Tab | Show a function's documentation |
(Colab uses similar ideas but with different keys, such as Ctrl + M followed by a letter. Check Tools → Keyboard shortcuts.)
9.5 Magic Commands
Special commands that start with % (one line) or %% (whole cell).
| Command | Purpose |
|---|---|
%matplotlib inline | Show plots inside the notebook (often the default) |
%timeit expr | Time a short expression precisely |
%%time | Time the whole cell |
%who / %whos | List variables in memory |
%run script.py | Run an external Python file |
%load_ext autoreload%autoreload 2 | Auto-reload edited modules |
!command | Run a shell command, e.g. !ls, !pip install seaborn |
?name or help(name) | Show documentation |
%timeit sum(range(1000))
!pip list
9.6 Google Colab Specifics
Getting started
- Go to colab.research.google.com and sign in with a Google account.
- Create a New notebook or open one from Google Drive or GitHub.
Using a GPU / TPU
Runtime → Change runtime type → choose a hardware accelerator. Check it with:
!nvidia-smi # shows GPU info (if a GPU is assigned)
Installing packages
!pip install some-package # installs only for the current session
Colab sessions reset when they end, so reinstall in each new session (put install commands in the first cell).
Working with files
Upload a file (temporary):
from google.colab import files
uploaded = files.upload()
Mount Google Drive (so files persist):
from google.colab import drive
drive.mount("/content/drive")
df = pd.read_csv("/content/drive/MyDrive/data/titanic.csv")
Download a file:
files.download("results.csv")
⚠️ Files saved in Colab's local storage (
/content/) are deleted when the session ends. Save important outputs to Drive.
Colab niceties
- Text cells support Markdown and LaTeX
- Forms and Table of Contents for tidy notebooks
- Saves automatically to Google Drive, with version history
- Easy sharing and commenting
9.7 Best Practices for Notebooks
- ✅ Give the notebook a clear structure: title, sections with Markdown headings, short explanations
- ✅ Put imports and installs in the first cells
- ✅ Keep cells small and focused
- ✅ Set random seeds for reproducibility
- ✅ Restart & Run All before sharing to confirm it works top-to-bottom
- ✅ Move reusable code into functions (or
.pymodules) - ❌ Avoid hidden state: don't rely on cells you ran once and deleted
- ❌ Don't commit large outputs or secrets (API keys, passwords) to shared notebooks
| Use notebooks for | Prefer .py scripts for |
|---|---|
| Exploration, visualization, teaching, reporting | Production code, automation, reusable modules |
🧠 Why ML cares
ML is experimental: you try something, look at the result, adjust. Notebooks give instant feedback with code, plots and notes together, and Colab removes the setup and hardware barrier.
10. Virtual Environments and Package Management
10.1 The Problem They Solve
Different projects need different versions of libraries:
Project A needs scikit-learn 1.2 ┐ Installing both globally
Project B needs scikit-learn 1.5 ┘ → they CONFLICT 💥
A virtual environment is an isolated folder with its own Python interpreter and its own set of installed packages. Each project gets its own, so nothing clashes.
System Python (don't pollute it!)
├── project_A/ → .venv with scikit-learn 1.2
└── project_B/ → .venv with scikit-learn 1.5
Benefits
- ✅ No version conflicts between projects
- ✅ Reproducibility: others can recreate your exact setup
- ✅ Keeps your system Python clean
- ✅ Safe to experiment (just delete the environment if it breaks)
10.2 Option A: venv (built into Python)
Create
python -m venv .venv # creates a folder named .venv
(On some systems, use python3 instead of python.)
Activate
| Operating system | Command |
|---|---|
| Windows (Command Prompt) | .venv\Scripts\activate |
| Windows (PowerShell) | .venv\Scripts\Activate.ps1 |
| macOS / Linux | source .venv/bin/activate |
When active, your terminal prompt shows (.venv).
Deactivate
deactivate
Typical workflow
mkdir my_ml_project && cd my_ml_project
python -m venv .venv
source .venv/bin/activate # (Windows: .venv\Scripts\activate)
pip install numpy pandas matplotlib seaborn scikit-learn jupyter
pip freeze > requirements.txt # record exact versions
10.3 Package Management with pip
pip is Python's standard package installer (it downloads from PyPI, the Python Package Index).
| Task | Command |
|---|---|
| Install a package | pip install pandas |
| Install a specific version | pip install pandas==2.2.2 |
| Install with a version range | pip install "pandas>=2.0,<3.0" |
| Upgrade a package | pip install --upgrade pandas |
| Uninstall | pip uninstall pandas |
| List installed packages | pip list |
| Show package details | pip show pandas |
| Save the environment | pip freeze > requirements.txt |
| Recreate an environment | pip install -r requirements.txt |
| Check for conflicts | pip check |
requirements.txt: the shareable recipe
numpy==1.26.4
pandas==2.2.2
scikit-learn==1.5.0
matplotlib==3.9.0
seaborn==0.13.2
Anyone can run pip install -r requirements.txt in a fresh environment to reproduce your setup.
Install name ≠ import name
pip install ... | import ... |
|---|---|
scikit-learn | sklearn |
opencv-python | cv2 |
pillow | PIL |
pyyaml | yaml |
numpy, pandas, matplotlib, seaborn, scipy | same name |
10.4 Option B: Conda (Anaconda / Miniconda)
Conda is both an environment manager and a package manager. It can install non-Python dependencies too and lets you choose the Python version per environment, so it's popular in data science.
- Anaconda: big distribution with many data science packages pre-installed.
- Miniconda: minimal installer (just conda + Python). You add what you need.
conda create -n mlenv python=3.11 # create an environment named "mlenv"
conda activate mlenv # activate
conda install numpy pandas scikit-learn # install packages
conda install -c conda-forge seaborn # install from a specific channel
conda list # list packages
conda env list # list all environments
conda deactivate # leave the environment
conda remove -n mlenv --all # delete the environment
conda env export > environment.yml # save the environment
conda env create -f environment.yml # recreate it elsewhere
venv + pip vs Conda
venv + pip | Conda | |
|---|---|---|
| Comes with | Python itself | Needs installing separately |
| Installs from | PyPI | Conda channels (and can also use pip) |
| Python version choice | Uses the Python that created it | Pick any version per environment |
| Non-Python dependencies | ❌ | ✅ |
| Size | Light | Heavier |
| Best for | Simple, lightweight projects | Data science with complex dependencies |
⚠️ If you mix
conda installandpip installin one environment, install conda packages first, then use pip for the rest, to avoid conflicts.
10.5 Using Your Environment in Jupyter
A notebook only sees packages from the environment its kernel uses. To make your environment appear as a kernel:
# inside the activated environment
pip install ipykernel
python -m ipykernel install --user --name=mlenv --display-name "Python (mlenv)"
Then in Jupyter choose Kernel → Change kernel → Python (mlenv).
(In a notebook cell, %pip install package installs into the running kernel's environment, which is safer than !pip install.)
10.6 Best Practices
- 🗂️ One environment per project, created inside (or named after) the project folder
- 📌 Pin versions in
requirements.txtfor reproducibility - 🚫 Add
.venv/to.gitignore. Never commit the environment itself, onlyrequirements.txt - 🔁 Re-generate
requirements.txtafter installing new packages - 🧹 Prefer a fresh environment to fix weird dependency problems
- ❌ Avoid installing everything into the global/system Python
- 📝 Document the Python version your project needs
10.7 Troubleshooting Cheat Table
| Problem | Likely cause / fix |
|---|---|
ModuleNotFoundError after installing | Wrong environment active, or notebook using a different kernel. Check which python (macOS/Linux) or where python (Windows) |
pip installs to the wrong Python | Use python -m pip install ... so pip matches the interpreter |
| PowerShell blocks activation | Run Set-ExecutionPolicy -Scope CurrentUser RemoteSigned, or use Command Prompt |
| Version conflicts | Create a clean environment and reinstall from requirements.txt |
| Package installed but import fails | Check the import name vs install name |
🧠 Why ML cares
ML libraries evolve quickly and depend on specific versions of each other. Isolated, reproducible environments save hours of "it works on my machine" debugging and let others reproduce your results.
11. Quick Revision Cheat Sheet
🐍 Python & OOP
| Concept | Remember |
|---|---|
| Data structures | List [] mutable, Tuple () immutable, Dict {k: v}, Set {} unique |
| Comprehension | [f(x) for x in items if cond] |
| Function | def name(args): ... return value; *args, **kwargs, defaults, lambda |
| Pitfall | Never use a mutable default argument like bucket=[] |
| Class | Blueprint; __init__(self, ...) sets attributes; methods use self |
| OOP pillars | Encapsulation, Inheritance, Polymorphism, Abstraction |
🔢 NumPy
| Concept | Remember |
|---|---|
| Array | np.array, shape (rows, cols), one dtype |
| Indexing | a[row, col], a[:, 1], boolean mask a[a > 0] |
| Vectorization | Operate on whole arrays, avoid Python loops |
| Broadcasting | Shapes compatible if equal or 1 (from the right) |
axis | axis=0 down the rows (one result per column); axis=1 across columns |
| Matrix multiply | A @ B (vs A * B element-wise) |
| Reproducibility | np.random.default_rng(seed) |
🐼 Pandas
| Task | Code |
|---|---|
| Load | pd.read_csv("f.csv") |
| Inspect | head(), info(), describe(), shape |
| Select | df["col"], df.loc[labels], df.iloc[positions], df[cond] |
| Missing | isna().sum(), dropna(), fillna() |
| Duplicates | drop_duplicates() |
| Group | df.groupby("c")["x"].mean() |
| Join | pd.merge(a, b, on="k", how="left") |
| Encode | pd.get_dummies(df, columns=[...]) |
📊 Visualization
| Goal | Plot |
|---|---|
| Distribution | sns.histplot, sns.boxplot |
| Relationship | sns.scatterplot, sns.pairplot |
| Correlations | sns.heatmap(df.corr(), annot=True) |
| Categories | sns.countplot, sns.barplot |
| Trends | plt.plot / sns.lineplot |
🔬 SciPy
| Module | Use |
|---|---|
stats | Distributions, t-tests, correlation |
optimize | minimize, curve_fit |
sparse | Memory-efficient matrices with mostly zeros |
spatial.distance | Distances (Euclidean, cosine) |
linalg, integrate, interpolate | Linear algebra, integrals, interpolation |
🤖 Scikit-learn
split → preprocess (fit on TRAIN only) → fit model → predict → evaluate → tune → save
| Concept | Remember |
|---|---|
| API | fit(), predict(), transform() |
| Split | train_test_split(X, y, test_size=0.2, random_state=42) |
| Pipeline | Chain preprocessing + model; prevents leakage |
| Validation | cross_val_score(model, X, y, cv=5) |
| Tuning | GridSearchCV / RandomizedSearchCV |
| Save | joblib.dump(model, "m.joblib") |
📓 Notebooks & Environments
| Topic | Remember |
|---|---|
| Jupyter vs Colab | Local vs cloud; Colab gives free GPU but temporary storage |
| Run cell | Shift + Enter; Restart & Run All before sharing |
| Magics | %timeit, %%time, !pip install |
| Virtual env | python -m venv .venv → activate → pip install |
| Reproduce | pip freeze > requirements.txt / pip install -r requirements.txt |
| Conda | conda create -n env python=3.11 → conda activate env |
✅ Final Takeaways
- Python basics and functions are the glue. Learn lists, dicts, comprehensions and functions well.
- OOP explains how ML libraries work: models are objects with
fitandpredictmethods. - NumPy is the engine. Think in arrays and vectorize instead of looping.
- Pandas loads and shapes real-world tabular data.
- Cleaning is where most of the effort and most of the model quality comes from.
- Matplotlib and Seaborn let you see problems and results.
- SciPy provides the scientific tools: statistics, optimisation, sparse data, distances.
- Scikit-learn gives one consistent interface for the whole ML workflow. Use pipelines and always fit on training data only.
- Jupyter and Colab make experimenting fast and shareable.
- Virtual environments keep projects isolated and reproducible.
#Data Collection and Preprocessing#
Friendly, beginner-to-advanced notes on getting data, understanding it, cleaning it, and shaping it so a model can learn from it. Every concept has a plain-English idea, a small example, runnable code, and a note on why it matters.
📑 Table of Contents
Part A: Getting to Know Your Data
- Data Types and Dataset Formats
- Data Collection and Data Acquisition
- Structured, Semi-Structured and Unstructured Data
- Exploratory Data Analysis (EDA)
- Data Quality Assessment
Part B: Cleaning 6. Handling Missing Values 7. Handling Duplicate Records 8. Outlier Detection and Treatment
Part C: Transforming Features 9. Categorical Data Encoding (Overview) 10. Label Encoding and One-Hot Encoding 11. Ordinal Encoding and Target Encoding 12. Feature Scaling 13. Normalization and Standardization 14. Discretization and Binning 15. Data Transformation
Part D: Preparing Data for Modelling 16. Handling Imbalanced Datasets 17. Sampling Techniques 18. Train, Validation and Test Splits 19. Data Leakage and Prevention 20. Data Pipelines Using Scikit-learn
🗺️ The Big Picture
Collect ─► Understand ─► Clean ─► Transform ─► Split ─► (Model)
│ │ │ │ │
sources EDA & missing, encode, train /
formats quality duplicates, scale, val /
outliers bin, ... test
▲
ALL of this is wrapped in a PIPELINE so that
steps are repeatable and free of leakage
🍳 Analogy: Preprocessing is the mise en place of machine learning, the washing, chopping and measuring before cooking. A brilliant chef (model) can't rescue spoiled ingredients (bad data).
Setup used in code examples
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
1. Data Types and Dataset Formats
1.1 Data Types
Knowing a column's type decides how you can summarise it, plot it, and encode it.
Data
┌─────┴──────┐
Numerical Categorical (plus: Text, Date/Time, Boolean)
┌────┴────┐ ┌─────┴─────┐
Discrete Continuous Nominal Ordinal
| Type | Meaning | Examples | Typical handling |
|---|---|---|---|
| Numerical: Discrete | Countable whole numbers | Number of children, clicks, rooms | Use as-is, scale if needed |
| Numerical: Continuous | Any value in a range | Height, price, temperature | Scale, check for skew and outliers |
| Categorical: Nominal | Categories with no order | Colour, city, blood group | One-hot, target encoding |
| Categorical: Ordinal | Categories with a meaningful order | Low/Medium/High, education level, star rating | Ordinal encoding |
| Binary | Exactly two values | Yes/No, Spam/Not spam | 0/1 |
| Date/Time | Points in time | Order date, timestamp | Extract year, month, weekday, etc. |
| Text | Free-form language | Reviews, emails | Text-specific processing |
⚠️ A number isn't always numerical. A
zip_codeorcustomer_idis stored as digits but is really a category (averaging zip codes is meaningless). Always think about meaning, not just storage type.
Levels of measurement (a more formal view)
| Level | Order? | Equal gaps? | True zero? | Example |
|---|---|---|---|---|
| Nominal | ❌ | ❌ | ❌ | Eye colour |
| Ordinal | ✅ | ❌ | ❌ | Satisfaction rating |
| Interval | ✅ | ✅ | ❌ | Temperature in °C (0 °C doesn't mean "no heat") |
| Ratio | ✅ | ✅ | ✅ | Height, weight, income (0 means none) |
Why it matters: ratios like "twice as much" only make sense for ratio data.
Data types in pandas
df.dtypes # int64, float64, object (text/mixed), bool, datetime64[ns], category
df["city"] = df["city"].astype("category")
df["date"] = pd.to_datetime(df["date"])
1.2 Key Dataset Vocabulary
| Term | Meaning |
|---|---|
| Record / sample / row | One observation |
| Feature / attribute / column | One measured property |
| Target / label | What we want to predict |
| Dimensionality | Number of features |
| Cardinality | Number of distinct values in a column (df["city"].nunique()) |
| Metadata | Data about the data (column descriptions, units, source, collection date) |
1.3 Common Dataset Formats
| Format | Extension | Structure | Pros | Cons | pandas |
|---|---|---|---|---|---|
| CSV | .csv | Plain-text table, comma-separated | Universal, human-readable | No types, large files are slow, quoting/encoding issues | read_csv |
| TSV | .tsv / .txt | Same, tab-separated | Avoids comma clashes in text | Same as CSV | read_csv(sep="\t") |
| Excel | .xlsx | Sheets of cells | Familiar, multiple sheets | Slow, messy formatting, size limits | read_excel |
| JSON | .json | Nested key-value | Flexible, standard for web APIs | Verbose, needs flattening | read_json, json_normalize |
| JSON Lines | .jsonl | One JSON object per line | Streams well, append-friendly | Not a single valid JSON doc | read_json(lines=True) |
| XML | .xml | Tagged hierarchy | Self-describing | Verbose | read_xml |
| Parquet | .parquet | Columnar, compressed, typed | Fast, small, keeps dtypes | Not human-readable | read_parquet |
| SQL database | tables | Relational tables | Query only what you need, handles big data | Needs a database | read_sql |
| HDF5 | .h5 | Hierarchical binary | Large numeric arrays | Less portable | read_hdf |
| NumPy | .npy / .npz | Binary arrays | Fast array storage | Arrays only | np.load |
| Images / audio / video | .jpg, .wav, .mp4 … | Binary media | Natural media format | Needs specialised loading | Other libraries |
| Pickle / joblib | .pkl, .joblib | Python objects | Save any Python object | Python-only, security risk with untrusted files | read_pickle |
df = pd.read_csv("data.csv", sep=",", encoding="utf-8", na_values=["?", "NA", "-"])
df = pd.read_csv("big.csv", usecols=["age", "city"], nrows=1000) # peek at a big file
for chunk in pd.read_csv("huge.csv", chunksize=100_000): # process big files in pieces
process(chunk)
df.to_parquet("data.parquet") # typed, compressed, fast
df.to_csv("clean.csv", index=False)
Choosing a format: CSV for sharing and simplicity, Parquet for large analytical datasets, JSON for nested/web data, SQL for data that lives in a database.
Common loading problems
- Encoding errors: try
encoding="latin-1"or"utf-8-sig" - Wrong delimiter: check
sep= - Numbers read as text because of stray symbols (
"$1,200") - Missing-value markers such as
"?","N/A",-999(usena_values=) - Dates not parsed (use
parse_dates=["col"])
2. Data Collection and Data Acquisition
Data collection is gathering raw data. Data acquisition is the broader process of obtaining it and bringing it into your system, whether by collecting it yourself, buying it, or pulling it from existing sources.
📌 Your model can only be as good as the data you feed it. Poor collection (biased, tiny, or noisy data) can't be fixed later by clever algorithms.
2.1 Where Data Comes From
| Source | Description | Examples |
|---|---|---|
| Primary data | Collected first-hand for your specific purpose | Surveys, experiments, interviews, A/B tests, sensors |
| Secondary data | Already collected by someone else | Public datasets, reports, company records |
| Internal systems | Your organisation's own data | Databases, CRM, transaction logs, web/app logs |
| Public/open datasets | Free datasets | Kaggle, UCI Machine Learning Repository, government open-data portals, Hugging Face datasets |
| APIs | Programmatic access to a service's data | Weather, finance, social platforms |
| Web scraping | Extracting data from web pages | Product prices, news articles |
| Sensors / IoT | Automatic measurements | Temperature, GPS, accelerometers |
| Crowdsourcing | People label or collect data | Image labelling platforms |
| Synthetic data | Artificially generated | Simulated records when real data is scarce or sensitive |
2.2 Collection Methods in Practice
Reading from a database
import sqlite3
conn = sqlite3.connect("shop.db")
df = pd.read_sql("SELECT customer_id, amount, date FROM orders WHERE date >= '2025-01-01'", conn)
Filter and select in SQL so you only pull the rows and columns you need.
Calling an API
import requests
response = requests.get(
"https://api.example.com/v1/items",
params={"limit": 100, "page": 1},
headers={"Authorization": "Bearer YOUR_TOKEN"},
timeout=10,
)
response.raise_for_status() # raises an error for 4xx/5xx responses
data = response.json() # parsed JSON
df = pd.json_normalize(data["items"])
APIs usually have rate limits (maximum requests per time), pagination (data returned in pages) and authentication.
Web scraping
from bs4 import BeautifulSoup
html = requests.get("https://example.com/products", timeout=10).text
soup = BeautifulSoup(html, "html.parser")
names = [tag.get_text(strip=True) for tag in soup.select(".product-name")]
Before scraping, check the site's terms of service and robots.txt, and prefer an official API when one exists.
Streaming vs batch acquisition
| Batch | Streaming | |
|---|---|---|
| Data arrives | In chunks at intervals (daily file) | Continuously, event by event |
| Example | Nightly export of sales | Live sensor feed, click stream |
| Suits | Reports, periodic retraining | Real-time monitoring and decisions |
2.3 Questions to Ask Before (and While) Collecting
- Is it relevant? Does it actually relate to what I'm predicting?
- Is it representative? Does it match the real situations the model will face (all customer types, seasons, regions)?
- Is it enough? Both rows (samples) and coverage of variation.
- Is it labelled? Who labels it, how consistently, at what cost?
- Is it legal and ethical? Consent, privacy, licences, and sensitive attributes.
- Is it documented? Source, date, units, definitions.
2.4 Pitfalls in Collection
| Pitfall | What goes wrong |
|---|---|
| Sampling bias | Collected data isn't representative (e.g., an online survey misses people without internet) |
| Survivorship bias | Only successful or surviving cases are recorded |
| Selection bias | The way you picked data skews it |
| Measurement error | Faulty sensors or inconsistent definitions |
| Label noise | Wrong or inconsistent labels |
| Stale data | The world changed since collection |
| Privacy violations | Personal data collected without consent or safeguards |
2.5 Good Practices
- ✅ Document everything: source, collection date, units, known issues (a "data dictionary")
- ✅ Keep the raw data untouched. Do cleaning in scripts so it's reproducible
- ✅ Version your datasets so experiments can be reproduced
- ✅ Respect privacy and licences: anonymise or remove personal identifiers where appropriate
- ✅ Store data in sensible formats (e.g., Parquet for large tables)
3. Structured, Semi-Structured and Unstructured Data
3.1 The Three Kinds
| Structured | Semi-structured | Unstructured | |
|---|---|---|---|
| Organisation | Fixed schema: rows and columns | Has tags/keys but flexible, nested structure | No predefined format |
| Examples | Spreadsheets, SQL tables, CSV | JSON, XML, YAML, log files, HTML | Text documents, images, audio, video, emails |
| Stored in | Relational databases | Document stores, files | File systems, data lakes |
| Easy to query? | ✅ Very (SQL) | ⚠️ Moderate | ❌ Needs extra processing |
| ML-ready? | Nearly | After flattening | After feature extraction |
| Share of real-world data | Smaller | Medium | Largest |
Structured Semi-structured Unstructured
┌────┬─────┬──────┐ { "The delivery was late
│ id │ age │ city │ "id": 1, but the product is
├────┼─────┼──────┤ "profile": {"age": 25}, great, thanks!"
│ 1 │ 25 │ Pune │ "tags": ["a", "b"]
└────┴─────┴──────┘ } [ an image of pixels ]
3.2 Working with Each Kind
Structured
Ready for tables and SQL. Concentrate on cleaning and encoding.
Semi-structured: flatten it into a table
import json
records = [
{"id": 1, "user": {"name": "Asha", "age": 25}, "tags": ["new", "vip"]},
{"id": 2, "user": {"name": "Ravi", "age": 31}, "tags": []},
]
df = pd.json_normalize(records)
# id tags user.name user.age ← nested keys become "user.name", "user.age"
df["n_tags"] = df["tags"].apply(len) # turn list-valued fields into usable numbers
df = df.explode("tags") # or: one row per list element
Common work: flatten nested fields, handle missing keys (not every record has the same fields), parse lists into counts or indicator columns.
Unstructured: convert to numbers first
Models need numeric input, so unstructured data must be turned into features:
| Data | Typical way to make it numeric |
|---|---|
| Text | Clean the text, then counts/weights of words (e.g., bag-of-words, TF-IDF) or learned embeddings |
| Images | Arrays of pixel values (height × width × channels), usually resized and scaled |
| Audio | Waveform samples or derived features (e.g., spectrograms) |
| Video | Sequences of image frames (plus audio) |
# Image as numbers
from PIL import Image
img = np.array(Image.open("cat.jpg").resize((64, 64))) # shape (64, 64, 3)
img = img / 255.0 # scale pixels to 0–1
💡 The unstructured → numbers step is called feature extraction. Once you have numeric features, most of the later preprocessing in these notes applies.
4. Exploratory Data Analysis (EDA)
EDA is getting to know your data before modelling: summarising it, visualising it, and hunting for patterns, problems and surprises. Coined by statistician John Tukey, it's about asking questions and letting the data answer.
4.1 Goals of EDA
- 🔍 Understand structure, types and size
- 🩺 Find problems: missing values, duplicates, outliers, wrong types
- 📈 Understand distributions and relationships
- 🎯 See how features relate to the target
- 💡 Generate ideas for features and the choice of model
- ✅ Check assumptions (and spot data that doesn't match them)
4.2 The EDA Workflow
1. Overview → size, types, head/tail, memory
2. Quality scan → missing, duplicates, impossible values
3. Univariate → each variable on its own
4. Bivariate → pairs (feature vs feature, feature vs target)
5. Multivariate → many at once (correlation matrix, pair plots)
6. Target analysis → distribution, class balance
7. Write findings → what to clean / transform / engineer
4.3 Step 1: First Look
df.shape # (rows, columns)
df.head() # first rows
df.sample(5, random_state=1) # a random peek (better than head for ordered files)
df.info() # dtypes, non-null counts, memory
df.describe() # numeric summary: count, mean, std, min, quartiles, max
df.describe(include="object") # categorical summary: count, unique, top, freq
df.nunique() # cardinality of every column
Reading describe(): compare mean vs median (50%). A big gap suggests skew or outliers. Check min and max for impossible values (age = −5 or 999).
4.4 Step 2: Univariate Analysis (One Variable)
Numeric
df["income"].agg(["mean", "median", "std", "min", "max", "skew", "kurt"])
sns.histplot(df["income"], kde=True) # shape of the distribution
sns.boxplot(x=df["income"]) # median, spread, outliers
| Look for | Meaning |
|---|---|
| Skewness > 0 (long right tail) | Few very large values (income, prices), a candidate for a log transform |
| Skewness < 0 (long left tail) | Few very small values |
| ** | Skewness |
| Multiple peaks (multimodal) | Possibly different groups mixed together |
| Spikes at odd values (0, 999, −1) | Placeholders for missing data |
Categorical
df["city"].value_counts()
df["city"].value_counts(normalize=True) # proportions
sns.countplot(data=df, y="city", order=df["city"].value_counts().index)
Watch for rare categories, typos ("delhi" vs "Delhi "), and very high cardinality.
4.5 Step 3: Bivariate Analysis (Two Variables)
| Pair | Tools |
|---|---|
| Numeric vs Numeric | Scatter plot, correlation (df["a"].corr(df["b"])) |
| Numeric vs Categorical | Box/violin plot per category, group means: df.groupby("city")["income"].mean() |
| Categorical vs Categorical | Cross-tab: pd.crosstab(df["city"], df["bought"], normalize="index") |
sns.scatterplot(data=df, x="age", y="income", hue="bought")
sns.boxplot(data=df, x="city", y="income")
4.6 Step 4: Multivariate Analysis (Many Variables)
corr = df.corr(numeric_only=True)
sns.heatmap(corr, annot=True, fmt=".2f", cmap="coolwarm", vmin=-1, vmax=1)
sns.pairplot(df[["age", "income", "tenure", "target"]], hue="target")
Use these to find:
- Strongly correlated feature pairs (redundancy)
- Features correlated with the target (potentially useful)
- Suspiciously high correlation with the target (possible leakage, see Section 19)
4.7 Step 5: Analyse the Target
df["target"].value_counts(normalize=True) # class balance (classification)
sns.histplot(df["price"], kde=True) # distribution (regression)
Check for class imbalance and for a skewed numeric target.
4.8 Time-Based Data in EDA
df["date"] = pd.to_datetime(df["date"])
df.set_index("date")["sales"].resample("M").sum().plot() # monthly trend
Look for trends, seasonality, gaps, and sudden shifts.
4.9 EDA Questions Checklist
- How big is the dataset? Is it large enough?
- Are the data types right?
- How much is missing, and where?
- Are there duplicates?
- Are values plausible (ranges, units)?
- How is each variable distributed? Any heavy skew?
- Any outliers? Errors or real?
- Which features relate to the target?
- Are any features redundant?
- Is the target balanced?
- Does anything look "too good to be true"?
4.10 EDA Tips and Traps
- ✅ Do EDA on the training data only, or at least don't let test-set insights drive decisions (see leakage)
- ✅ Record findings as you go: they become your cleaning to-do list
- ✅ Plot, don't just compute. Different datasets can share the same summary statistics yet look completely different
- ❌ Correlation ≠ causation
- ❌ Don't ignore domain knowledge. Ask what values should look like
- ❌ Don't over-interpret patterns in small samples
🧰 Automated profiling tools (such as
ydata-profiling) can generate a quick EDA report. They save time but never replace your own judgement.
5. Data Quality Assessment
Data quality assessment measures how fit your data is for its purpose before you start fixing it. Think of it as a health check-up that produces a to-do list.
5.1 Dimensions of Data Quality
| Dimension | Question | Example problem |
|---|---|---|
| Completeness | Is all required data present? | 30% of income is empty |
| Accuracy | Are values correct and truthful? | Age = 250; a wrong label |
| Consistency | Do values agree across fields and sources? | "NY", "New York", "new york"; end date earlier than start date |
| Uniqueness | Are records free of duplicates? | The same customer listed three times |
| Validity | Do values follow allowed formats and ranges? | Email without @; negative quantity |
| Timeliness | Is the data up to date? | Prices from 3 years ago |
| Integrity | Do relationships hold? | An order refers to a customer ID that doesn't exist |
| Representativeness | Does it reflect the real population? | Only one region is covered |
| Balance | Are classes/groups reasonably covered? | 99.5% of the rows are one class |
5.2 A Practical Quality Report
def quality_report(df):
report = pd.DataFrame({
"dtype": df.dtypes,
"missing": df.isna().sum(),
"missing_%": (df.isna().mean() * 100).round(2),
"n_unique": df.nunique(),
})
report["constant"] = report["n_unique"] <= 1 # same value everywhere
return report
print(quality_report(df))
print("Duplicate rows:", df.duplicated().sum())
5.3 Specific Checks
# 1. Completeness
df.isna().mean().sort_values(ascending=False)
# 2. Uniqueness
df.duplicated().sum()
df["customer_id"].is_unique
# 3. Validity (rules based on domain knowledge)
df[(df["age"] < 0) | (df["age"] > 120)]
df[df["quantity"] <= 0]
df[~df["email"].str.contains("@", na=False)]
# 4. Consistency
df["state"].str.strip().str.lower().value_counts() # reveals spelling variants
df[df["end_date"] < df["start_date"]] # logical contradictions
# 5. Placeholder values pretending to be real
(df == -999).sum() # also check 0, 9999, "unknown", "?", ""
# 6. Wrong dtype (numbers stored as text)
pd.to_numeric(df["price"], errors="coerce").isna() & df["price"].notna()
# 7. Suspicious columns
df.columns[df.nunique() == 1] # constant columns carry no information
df.select_dtypes("object").nunique() # very high cardinality, e.g. IDs
# 8. Referential integrity
orders[~orders["customer_id"].isin(customers["customer_id"])]
# 9. Timeliness
df["date"].min(), df["date"].max()
5.4 Also Assess the Labels
For supervised learning, the target's quality matters hugely:
- Are labels correct and consistently defined?
- Do different labelers disagree on the same item?
- Are there contradictory duplicates (identical inputs, different labels)?
# identical features but different labels = label conflict
feature_cols = [c for c in df.columns if c != "label"]
conflicts = df.groupby(feature_cols)["label"].nunique()
conflicts[conflicts > 1]
5.5 Turning Findings into Actions
| Finding | Typical action |
|---|---|
| High % missing in a column | Impute, add a missing-indicator, or drop the column |
| Duplicates | Remove (after checking they're not legitimate repeats) |
| Impossible values | Fix, set to missing, or remove |
| Inconsistent categories | Standardise text (strip, lowercase, map variants) |
| Constant columns | Drop |
| ID-like columns | Drop from features |
| Wrong dtypes | Convert |
| Heavy imbalance | See Section 16 |
| Possible leakage columns | Investigate and remove |
5.6 Good Practice
- Define data quality rules up front, and re-run them whenever new data arrives
- Keep a log of issues and fixes
- Quality isn't a one-time step. Monitor it for incoming data too
6. Handling Missing Values
Missing values are the most common data problem, and they matter because most algorithms can't accept NaN as input.
6.1 Why Is Data Missing? (The Three Types)
Understanding why guides what you do.
| Type | Meaning | Example | Risk |
|---|---|---|---|
| MCAR, Missing Completely At Random | Missingness has no pattern; pure chance | A sensor randomly drops a reading | Safest. Deleting rows causes little bias |
| MAR, Missing At Random | Missingness depends on other observed columns | Younger respondents skip the income question | Can be modelled using other columns |
| MNAR, Missing Not At Random | Missingness depends on the missing value itself | High earners refuse to report income | Hardest; simple fixes can bias results |
💡 In MNAR cases, the fact that a value is missing is itself informative, so adding a "was missing" indicator can help.
6.2 Detecting Missing Values
df.isna().sum() # count per column
(df.isna().mean() * 100).round(1) # percentage per column
df[df.isna().any(axis=1)] # rows with any missing value
df.isna().sum(axis=1).value_counts() # how many missing per row
sns.heatmap(df.isna(), cbar=False) # visual map of where the gaps are
Also hunt for disguised missing values: 0, -1, 999, "unknown", "N/A", "".
df = df.replace({"?": np.nan, "unknown": np.nan, -999: np.nan})
6.3 Strategy Overview
Missing values
┌───────┴────────┐
DELETE IMPUTE (fill in)
┌──────┴──────┐ ┌─────────┼───────────────┐
rows columns Simple Group/Time-aware Model-based
(mean, (group mean, (KNN,
median, ffill, interpolate) iterative)
mode,
constant)
6.4 Deletion
df.dropna() # drop rows with ANY missing value
df.dropna(subset=["target"]) # drop rows missing in specific columns
df.dropna(thresh=5) # keep rows with at least 5 non-missing values
df.dropna(axis=1, thresh=int(0.6 * len(df))) # drop columns that are >40% empty
| ✅ Delete when | ❌ Avoid when |
|---|---|
| Very few rows affected (e.g., < 5%) and MCAR | Many rows are affected (you lose lots of data) |
| The target itself is missing | Missingness isn't random (introduces bias) |
| A column is almost entirely empty | The dataset is small |
6.5 Simple Imputation
| Strategy | Use for | Notes |
|---|---|---|
| Mean | Numeric, roughly symmetric | Sensitive to outliers |
| Median | Numeric, skewed or with outliers | A safer default |
| Mode (most frequent) | Categorical | |
Constant (e.g., "Unknown", 0) | When "missing" has a meaning | Makes missingness explicit |
df["age"] = df["age"].fillna(df["age"].median())
df["city"] = df["city"].fillna(df["city"].mode()[0])
df["coupon"] = df["coupon"].fillna("None")
Drawbacks of simple imputation: it shrinks variance (many identical filled values) and can weaken correlations, and it ignores relationships with other features.
6.6 Smarter Imputation
Group-wise imputation
Use the typical value within a relevant group:
df["income"] = df["income"].fillna(df.groupby("job")["income"].transform("median"))
Time-series imputation
df["temp"] = df["temp"].ffill() # carry the last value forward
df["temp"] = df["temp"].bfill() # or pull the next value backward
df["temp"] = df["temp"].interpolate() # draw a line between known points
KNN imputation: fill using the most similar rows
from sklearn.impute import KNNImputer
imputer = KNNImputer(n_neighbors=5)
X_filled = imputer.fit_transform(X) # numeric data; scale first for fairness
Iterative (model-based) imputation
Predicts each column with missing values from the other columns, in rounds.
from sklearn.experimental import enable_iterative_imputer # required (experimental feature)
from sklearn.impute import IterativeImputer
imputer = IterativeImputer(max_iter=10, random_state=42)
X_filled = imputer.fit_transform(X)
6.7 Missing-Value Indicators
Keep the information that a value was missing:
df["income_missing"] = df["income"].isna().astype(int) # manual flag
df["income"] = df["income"].fillna(df["income"].median())
or let Scikit-learn do it:
from sklearn.impute import SimpleImputer
imputer = SimpleImputer(strategy="median", add_indicator=True) # appends indicator columns
6.8 Scikit-learn's SimpleImputer
from sklearn.impute import SimpleImputer
num_imp = SimpleImputer(strategy="median") # "mean", "median", "most_frequent", "constant"
cat_imp = SimpleImputer(strategy="constant", fill_value="Unknown")
X_train_imp = num_imp.fit_transform(X_train) # learn medians from TRAIN only
X_test_imp = num_imp.transform(X_test) # reuse the same medians on test
🚨 Leakage alert: compute fill values (mean, median, etc.) from the training set only, then apply to validation/test data. See Section 19.
6.9 Which Method Should I Use?
| Situation | Suggestion |
|---|---|
| < 5% missing, MCAR | Deleting rows is acceptable |
| Numeric, skewed | Median |
| Categorical | Mode or a new "Unknown" category |
| Missingness likely informative | Add indicator column(s) |
| Time series | Forward fill / interpolation |
| Strong relationships between features | KNN or iterative imputation |
| > 60–70% missing in a column | Consider dropping the column |
| Target is missing | Drop those rows |
ℹ️ Some tree-based models (such as Scikit-learn's
HistGradientBoosting*) can handleNaNnatively, so imputation isn't always mandatory. Most others need it.
7. Handling Duplicate Records
Duplicates are repeated records of the same real-world thing. They distort statistics, bias the model toward repeated cases, and (worst) can leak data between train and test sets.
7.1 Types of Duplicates
| Type | Description | Example |
|---|---|---|
| Exact duplicates | Every column identical | Row inserted twice |
| Partial / key duplicates | Same key (ID, email), different other fields | Same customer with two addresses |
| Near (fuzzy) duplicates | Almost the same, small differences | "Jon Smith" vs "John Smith", extra spaces, different capitalisation |
7.2 Why They Appear
Repeated data imports, system glitches, merging datasets, user double-submissions, web scraping the same page twice, or data entry variations.
7.3 Detecting Duplicates
df.duplicated().sum() # number of exact duplicate rows
df[df.duplicated(keep=False)].sort_values("id") # see ALL copies (keep=False marks every copy)
df.duplicated(subset=["email"]).sum() # duplicates by a key column
df["customer_id"].is_unique
df["customer_id"].value_counts().loc[lambda s: s > 1] # which IDs repeat?
keep= options: "first" (default: marks all but the first), "last", False (marks all copies).
7.4 Removing Duplicates
df = df.drop_duplicates() # exact duplicates
df = df.drop_duplicates(subset=["email"], keep="first") # by key
df = df.drop_duplicates(subset=["email"], keep="last") # keep the latest (if sorted by date)
Keep the most recent record:
df = df.sort_values("updated_at").drop_duplicates(subset="customer_id", keep="last")
7.5 Handling Near-Duplicates
First normalise the text so near-duplicates become exact duplicates:
df["name_clean"] = (
df["name"].str.strip().str.lower().str.replace(r"\s+", " ", regex=True)
)
df["email_clean"] = df["email"].str.strip().str.lower()
df = df.drop_duplicates(subset=["email_clean"])
For stubborn cases, fuzzy matching (comparing string similarity) can flag likely duplicates for review. Always review matches rather than deleting blindly.
7.6 Merging Instead of Dropping
When duplicates hold complementary information, combine them:
df = df.groupby("customer_id").agg({
"email": "first",
"total_spent": "sum",
"last_login": "max",
}).reset_index()
7.7 ⚠️ When Duplicates Are Legitimate
Not every repeat is an error:
- Two different customers who bought the same item at the same price
- Repeated measurements (the same patient measured on several days)
- Naturally repeated events (transactions)
Check with domain knowledge and an identifier or timestamp before removing.
7.8 Duplicates and Data Splitting
If a record appears in both training and test sets, your test score is artificially inflated: the model has effectively seen the answer.
✅ Remove duplicates before splitting, and for grouped data (e.g., several rows per patient), keep each group entirely on one side of the split (see Section 18).
8. Outlier Detection and Treatment
An outlier is a data point that differs markedly from the rest. It may be an error (typo, sensor fault) or a genuine rare event (fraud, a very rich customer).
8.1 Types of Outliers
| Type | Meaning | Example |
|---|---|---|
| Point (global) | One value far from everything | Age = 250 |
| Contextual | Unusual given the context | 30 °C is normal in July but odd in January |
| Collective | A group of points is unusual together | A flat-lined sensor for 2 hours |
Outliers can also be univariate (extreme in one variable) or multivariate (a normal-looking combination in each variable but unusual together, such as a 5-year-old with an income of ₹10 lakh).
8.2 Why They Matter
- Distort means, standard deviations and correlations
- Strongly affect sensitive models (e.g., linear regression, distance-based methods)
- Skew scaling (e.g., min-max scaling is dominated by extremes)
- But they can also be the most important points (fraud detection!)
8.3 Detection Methods
1) Visual
sns.boxplot(x=df["income"]) # dots beyond the whiskers = candidates
sns.histplot(df["income"]) # lone bars far from the mass
sns.scatterplot(data=df, x="age", y="income") # find odd pairs (multivariate)
2) Z-score method (assumes roughly bell-shaped data)
z = (df["income"] - df["income"].mean()) / df["income"].std()
outliers = df[z.abs() > 3] # more than 3 standard deviations away
⚠️ The mean and std are themselves affected by outliers, so extreme values can hide themselves.
3) IQR method (robust, no normality assumption)
Q1 = df["income"].quantile(0.25)
Q3 = df["income"].quantile(0.75)
IQR = Q3 - Q1
lower = Q1 - 1.5 * IQR
upper = Q3 + 1.5 * IQR
outliers = df[(df["income"] < lower) | (df["income"] > upper)]
This is the rule behind box-plot whiskers. Use 3 × IQR instead of 1.5 to flag only extreme outliers.
4) Modified Z-score (uses the median, robust)
median = df["income"].median()
mad = (df["income"] - median).abs().median() # Median Absolute Deviation
modified_z = 0.6745 * (df["income"] - median) / mad
outliers = df[modified_z.abs() > 3.5]
5) Percentile method
low, high = df["income"].quantile([0.01, 0.99])
outliers = df[(df["income"] < low) | (df["income"] > high)]
6) Model-based (good for multivariate outliers)
from sklearn.ensemble import IsolationForest
iso = IsolationForest(contamination=0.02, random_state=42) # expected outlier fraction
labels = iso.fit_predict(df[["age", "income"]]) # -1 = outlier, 1 = normal
outliers = df[labels == -1]
Others: Local Outlier Factor (compares a point's local density to its neighbours'), DBSCAN clustering (points not in any cluster are noise), Elliptic Envelope (assumes Gaussian data).
| Method | Best for | Weakness |
|---|---|---|
| Z-score | Bell-shaped, single feature | Distorted by the outliers themselves |
| IQR | Skewed data, single feature | Univariate only |
| Modified Z | Robust single-feature check | Univariate only |
| Isolation Forest / LOF | Many features, complex data | Needs tuning; harder to explain |
8.4 Worked Example (IQR)
Data: [12, 14, 15, 16, 18, 19, 20, 95]
Q1 ≈ 14.75,Q3 ≈ 19.25,IQR = 4.5- Lower fence
= 14.75 − 6.75 = 8.0; upper fence= 19.25 + 6.75 = 26.0 95 > 26→ flagged as an outlier ✅
8.5 Treatment Options
| Option | How | When to use |
|---|---|---|
| Remove | Drop the rows | Clear errors, and you can spare the data |
| Cap / Winsorize | Clip to lower/upper limits | Keep the row but tame the extreme |
| Transform | Log, square-root, etc. | Right-skewed data with a long tail |
| Impute | Treat as missing, then fill | Impossible values (age = 250) |
| Separate modelling | Treat the outlier group differently | A distinct segment (e.g., VIP customers) |
| Keep | Do nothing | They're genuine and informative (fraud!) |
| Robust methods | Use outlier-tolerant models or scalers | Can't or shouldn't remove them |
# Winsorizing / capping
df["income_capped"] = df["income"].clip(lower, upper)
# Log transform to tame a long right tail
df["income_log"] = np.log1p(df["income"])
# Treat impossible values as missing
df.loc[(df["age"] < 0) | (df["age"] > 120), "age"] = np.nan
8.6 Decision Guide
Is the value impossible or clearly an error?
├─ Yes → fix it, set to missing, or remove
└─ No → Is it a real but extreme value?
├─ Does your model suffer from it? ─ Yes → cap / transform / robust method
└─ Is it the thing you want to detect (fraud, faults)? ─ Yes → KEEP it!
8.7 Cautions
- ❌ Don't delete outliers automatically. Investigate first.
- ❌ Learn limits (IQR fences, percentiles) from the training set only, then apply them to the test set
- ✅ Check outliers before scaling
- ✅ Document what you did and why
9. Categorical Data Encoding (Overview)
Most ML algorithms need numbers. Encoding converts categories (text labels) into numeric form without inventing false meaning.
⚠️ The core danger: Encoding
Red=1, Blue=2, Green=3tells a model that Green is "three times" Red and that Blue is "between" them. For unordered categories that is false information.
9.1 Choosing an Encoder at a Glance
| Encoder | Output | Best for | Watch out |
|---|---|---|---|
| Label | One integer per class | The target variable; tree models on features (with care) | Implies order |
| Ordinal | Integers following a real order | Ordered categories (low/med/high) | Order must be specified |
| One-hot | One 0/1 column per category | Nominal, low cardinality | Many columns for high cardinality |
| Target (mean) | Number from target statistics | High-cardinality nominal | Leakage risk; needs cross-fitting and smoothing |
| Frequency / count | How often each category appears | High-cardinality, when frequency is informative | Different categories with equal counts collide |
| Binary / hashing | Compact binary codes or hashed buckets | Very high cardinality | Less interpretable |
9.2 Cardinality Matters
| Cardinality | Examples | Usual approach |
|---|---|---|
| Low (≈ < 10–15) | Gender, payment type | One-hot |
| Medium | Country, product category | One-hot (with rare-category grouping) or target encoding |
| High (hundreds+) | Zip code, user ID, product ID | Target/frequency/hashing; or group rare values into "Other" |
Group rare categories:
counts = df["city"].value_counts()
rare = counts[counts < 20].index
df["city"] = df["city"].replace(rare, "Other")
9.3 Clean First!
df["city"] = df["city"].str.strip().str.lower() # merge "Delhi", "delhi ", "DELHI"
Encode only after inconsistent spellings are fixed.
9.4 Unseen Categories
New data at prediction time may contain a category never seen in training. Plan for it (handle_unknown="ignore" for one-hot; an "unknown" code for ordinal).
10. Label Encoding and One-Hot Encoding
10.1 Label Encoding
Assigns each category a unique integer (0, 1, 2, …).
Cat → 0 Dog → 1 Bird → 2
from sklearn.preprocessing import LabelEncoder
le = LabelEncoder()
y_encoded = le.fit_transform(["cat", "dog", "bird", "dog"]) # [1 2 0 2] (alphabetical: bird=0, cat=1, dog=2)
le.classes_ # ['bird' 'cat' 'dog']
le.inverse_transform([1, 2]) # ['cat' 'dog']
Important notes
LabelEncoderis designed for the target (y), one column at a time.- For input features, use
OrdinalEncoder(works on several columns, fits into pipelines). - The integers create an artificial order. Linear and distance-based models will misread it. Tree-based models are less affected.
| ✅ Good for | ❌ Bad for |
|---|---|
| Encoding class labels (target) | Nominal features in linear/distance-based models |
| Tree-based models (with care) | Anything where artificial order would mislead |
10.2 One-Hot Encoding
Creates one new binary column per category: 1 if the row has that category, else 0.
Colour → Red Blue Green
Red → 1 0 0
Blue → 0 1 0
Green → 0 0 1
Blue → 0 1 0
No false order is introduced: every category is equally "far" from the others.
With pandas
pd.get_dummies(df, columns=["colour"])
pd.get_dummies(df, columns=["colour"], drop_first=True, dtype=int) # drop one column; 0/1 ints
With Scikit-learn (preferred for ML pipelines)
from sklearn.preprocessing import OneHotEncoder
ohe = OneHotEncoder(handle_unknown="ignore", sparse_output=False)
X_train_ohe = ohe.fit_transform(X_train[["colour"]]) # learn categories from TRAIN
X_test_ohe = ohe.transform(X_test[["colour"]]) # same columns guaranteed on test
ohe.get_feature_names_out() # ['colour_Blue' 'colour_Green' 'colour_Red']
Why Scikit-learn's encoder beats get_dummies for modelling: it remembers the categories from training, so the test data gets exactly the same columns, and unseen categories are handled gracefully (all zeros with handle_unknown="ignore"). With get_dummies, train and test could end up with different columns.
The Dummy Variable Trap
The columns are perfectly redundant: if Red = 0 and Blue = 0, then Green must be 1. This perfect multicollinearity can break linear models with no regularisation.
Fix: drop one column: drop="first" (or drop_first=True). With k categories, keep k − 1 columns. (Tree models and regularised models are not troubled by it.)
OneHotEncoder(drop="first", handle_unknown="ignore") # note: combining drop with ignore has caveats in some versions
Pros and cons
| ✅ Pros | ❌ Cons |
|---|---|
| No fake ordering | Many columns for high-cardinality features ("curse of dimensionality") |
| Simple and widely supported | Sparse, memory-heavy data |
| Works well for linear and distance-based models | Doesn't capture any relationship between categories |
Tips
- Use for nominal features with low/medium cardinality
- Group rare categories first
- Keep it inside a pipeline (see Section 20)
10.3 Label vs One-Hot Compared
| Label Encoding | One-Hot Encoding | |
|---|---|---|
| Output | 1 column of integers | k binary columns |
| Implied order | ✅ Yes (artificial) | ❌ None |
| Dimensionality | Unchanged | Increases |
| Use for | Target; some tree models | Nominal features |
| Linear/distance models | ⚠️ Misleading | ✅ Suitable |
11. Ordinal Encoding and Target Encoding
11.1 Ordinal Encoding
Used when categories have a natural, meaningful order. You assign integers that respect that order.
low → 0 medium → 1 high → 2
from sklearn.preprocessing import OrdinalEncoder
enc = OrdinalEncoder(categories=[["low", "medium", "high"]]) # you specify the order!
X_enc = enc.fit_transform(df[["priority"]]) # [[0.] [1.] [2.]]
Handling several columns, each with its own order:
enc = OrdinalEncoder(
categories=[["low", "medium", "high"], ["primary", "secondary", "graduate", "postgraduate"]],
handle_unknown="use_encoded_value", unknown_value=-1,
)
Or with pandas:
order = {"low": 0, "medium": 1, "high": 2}
df["priority_num"] = df["priority"].map(order)
Key points
- ⚠️ Always specify the order. The default is alphabetical, which is usually wrong ("high" < "low" < "medium")
- The numeric gaps are an assumption: is "high" really twice "medium"? The encoding implies equal spacing
- Great for: education level, ratings, size (S/M/L/XL), severity
| ✅ Pros | ❌ Cons |
|---|---|
| Compact: one column | Assumes equal spacing between levels |
| Preserves order information | Only valid for genuinely ordered categories |
11.2 Target Encoding (Mean Encoding)
Replaces each category with a statistic of the target for that category, usually the mean target value.
Example (predicting whether a customer buys, 1 = bought):
| City | Rows | Average bought | → Encoded value |
|---|---|---|---|
| Pune | 40 | 0.30 | 0.30 |
| Delhi | 100 | 0.55 | 0.55 |
| Bhopal | 20 | 0.40 | 0.40 |
means = df.groupby("city")["bought"].mean()
df["city_te"] = df["city"].map(means)
This packs a lot of information into one column, even for a category with hundreds of values, which is why it's popular for high-cardinality features.
⚠️ Danger 1: Target Leakage
The encoded value is computed using the target. If you compute means on the same rows you then train on, each row's own label influences its feature, so the model can "cheat", leading to overfitting.
Prevention:
- Compute the encoding on the training set only, then apply to validation/test
- Use cross-fitting (out-of-fold encoding): encode each fold using statistics computed from the other folds
⚠️ Danger 2: Rare Categories → Noisy Estimates
A category with 2 rows and a mean of 1.0 is unreliable. Smoothing blends the category mean with the overall mean:
encoded = ( n · category_mean + m · global_mean ) / ( n + m )
n= number of rows in the categorym= smoothing strength (higher → trust the global mean more)
Worked example: global mean = 0.40; a city with n = 10, category mean = 0.80; m = 10:
encoded = (10 × 0.80 + 10 × 0.40) / (10 + 10) = 12 / 20 = 0.60
The raw 0.80 is pulled toward 0.40 because only 10 rows back it up. With n = 1000, the estimate would stay near 0.80.
Using Scikit-learn's TargetEncoder (version 1.3+)
from sklearn.preprocessing import TargetEncoder
te = TargetEncoder(smooth="auto", cv=5, random_state=42)
X_train_te = te.fit_transform(X_train[["city"]], y_train) # uses internal cross-fitting
X_test_te = te.transform(X_test[["city"]]) # uses encodings learned on all training data
fit_transform uses cross-fitting internally to reduce leakage. Because of that, fit(X, y).transform(X) isn't the same as fit_transform(X, y). Always use fit_transform on the training data.
Pros and cons
| ✅ Pros | ❌ Cons |
|---|---|
| One column, even for high cardinality | Leakage and overfitting if done carelessly |
| Captures the category–target relationship directly | Needs smoothing for rare categories |
| Often strong for tree/boosting models | Requires the target, so it's supervised preprocessing |
11.3 Other Handy Encodings
| Encoding | Idea | Example |
|---|---|---|
| Frequency / count encoding | Replace with how often the category occurs | df["city"].map(df["city"].value_counts(normalize=True)) |
| Binary encoding | Convert the integer code to binary digits across a few columns | 100 categories → ~7 columns instead of 100 |
| Hashing | Hash categories into a fixed number of buckets | Fixed width, handles unseen categories; collisions possible |
11.4 Which Encoder? Summary
Is it the TARGET? → LabelEncoder
Is there a natural ORDER? → OrdinalEncoder (specify the order)
Unordered, few categories? → OneHotEncoder
Unordered, MANY categories? → Target / frequency / hashing (carefully!) or group rare values
12. Feature Scaling
Feature scaling puts numeric features on comparable ranges.
12.1 The Problem
| Feature | Typical range |
|---|---|
| Age | 18 – 70 |
| Salary | 20,000 – 2,000,000 |
| Number of children | 0 – 5 |
Without scaling, salary dominates simply because its numbers are bigger, not because it's more important.
12.2 Which Algorithms Need Scaling?
| Need scaling ✅ | Why |
|---|---|
| Distance-based (k-NN, k-Means, SVM with RBF kernel) | Distances are dominated by large-range features |
| Gradient-based (linear/logistic regression, neural networks) | Converges faster and more stably when features are on similar scales |
| Regularised models (Ridge, Lasso) | Penalties treat all coefficients equally, so unscaled features are penalised unfairly |
| PCA | Components are driven by variance, which depends on scale |
| Usually don't need scaling ❌ | Why |
|---|---|
| Decision trees, Random Forests, Gradient Boosting | Splits depend on feature order, not magnitude |
| Naive Bayes (generally) | Works with per-feature distributions |
(It doesn't hurt trees, it's just usually unnecessary.)
12.3 Visual Intuition
Before scaling (elongated) After scaling (rounder)
salary salary
│ · · │ · ·
│ · · │ · · ·
│ · · Gradient descent │ · · · Gradient descent
│· · zig-zags slowly │ heads straight to the minimum
└──────── age └──────── age
12.4 Golden Rules
- Fit the scaler on the training data only, then apply (transform) to validation and test data
- Scale features, not the target (usually, and only if you have a specific reason)
- Don't scale one-hot columns or other 0/1 indicators
- Handle outliers first, since some scalers are very sensitive to them
13. Normalization and Standardization
🗣️ Terminology warning: "Normalization" is used loosely. It often means min-max scaling (rescale into a range), but in Scikit-learn the
Normalizerclass scales each row to unit length. In these notes: Normalization = rescaling to a range, Standardization = mean 0, std 1.
13.1 Min-Max Normalization
Rescales values to a fixed range, usually [0, 1].
x_scaled = (x − x_min) / (x_max − x_min)
from sklearn.preprocessing import MinMaxScaler
scaler = MinMaxScaler(feature_range=(0, 1))
X_train_s = scaler.fit_transform(X_train)
X_test_s = scaler.transform(X_test)
| ✅ Pros | ❌ Cons |
|---|---|
| Bounded, intuitive range | Very sensitive to outliers (one extreme squashes everyone else) |
| Preserves the shape of the distribution | Test data can fall outside [0, 1] |
| Good for bounded-input models (e.g., image pixels, some neural nets) |
13.2 Standardization (Z-score Scaling)
Shifts to mean 0 and scales to standard deviation 1.
z = (x − μ) / σ
from sklearn.preprocessing import StandardScaler
scaler = StandardScaler()
X_train_s = scaler.fit_transform(X_train)
X_test_s = scaler.transform(X_test)
scaler.mean_, scaler.scale_ # learned mean and standard deviation per feature
| ✅ Pros | ❌ Cons |
|---|---|
| Not confined to a range, so less distorted by outliers than min-max | Doesn't produce a bounded range |
| Standard choice for many algorithms (linear models, SVM, PCA, neural nets) | Still affected by extreme outliers (mean and std shift) |
| Values mean "how many standard deviations from average" |
Note: Scikit-learn's
StandardScalerdivides by the population standard deviation (ddof = 0).
13.3 Worked Comparison
Data: [10, 20, 30, 40, 100] (the 100 is an outlier)
| Value | Min-Max | Standardized | Robust |
|---|---|---|---|
| 10 | 0.000 | −0.949 | −1.0 |
| 20 | 0.111 | −0.632 | −0.5 |
| 30 | 0.222 | −0.316 | 0.0 |
| 40 | 0.333 | 0.000 | 0.5 |
| 100 | 1.000 | 1.897 | 3.5 |
- Min-max: the outlier squeezes the four normal values into 0–0.33
- Standardized: mean = 40, std ≈ 31.6; the outlier still pulls the mean and std
- Robust: median = 30, IQR = 20, so the four normal values stay nicely spread out, and the outlier stays visibly extreme
13.4 Other Scalers
| Scaler | Formula / idea | Use when |
|---|---|---|
| RobustScaler | (x − median) / IQR | Data has outliers |
| MaxAbsScaler | x / max(|x|) → range [−1, 1] | Sparse data (keeps zeros as zeros) |
| Normalizer | Scales each row (sample) to unit length (L2 norm = 1) | When only the direction matters (e.g., text vectors, cosine similarity) |
| Log / power scaling | See Section 15 | Skewed data |
from sklearn.preprocessing import RobustScaler, MaxAbsScaler, Normalizer
RobustScaler().fit_transform(X_train)
Normalizer(norm="l2").fit_transform(X) # operates ROW by ROW (no fitting needed)
13.5 Normalization vs Standardization
| Min-Max Normalization | Standardization | |
|---|---|---|
| Result range | [0, 1] (or chosen) | Unbounded; mean 0, std 1 |
| Outlier sensitivity | High | Moderate |
| Distribution shape | Preserved | Preserved |
| Typical use | Images, bounded inputs, some neural nets | Linear/logistic regression, SVM, PCA, most general use |
| When unsure | ✅ Good default |
13.6 Quick Decision Guide
Outliers present? → RobustScaler (or treat outliers, then standardize)
Need values strictly in [0, 1]? → MinMaxScaler
Sparse data (lots of zeros)? → MaxAbsScaler
Roughly bell-shaped / general purpose? → StandardScaler
Tree-based model? → Scaling usually unnecessary
13.7 Reminder: No Leakage
# ❌ WRONG: statistics from ALL data, including the test set
scaler.fit(X_all); X_all_s = scaler.transform(X_all); split afterwards
# ✅ RIGHT: split first, learn from the training set only
X_train, X_test = train_test_split(X, ...)
scaler.fit(X_train)
X_train_s = scaler.transform(X_train)
X_test_s = scaler.transform(X_test)
14. Discretization and Binning
Discretization (binning) converts a continuous numeric feature into discrete intervals ("bins").
Age: 5, 17, 23, 34, 41, 58, 72
↓ bins: [0–18), [18–40), [40–65), [65+)
Group: Child, Child, Adult, Adult, Middle, Middle, Senior
14.1 Why Bin?
- 🧩 Capture non-linear effects with simple models (e.g., "teenagers behave differently")
- 🔇 Reduce the influence of noise and small fluctuations
- 🎯 Tame outliers (extremes fall into the top bin)
- 📖 Make results interpretable ("age band 25–35")
- 🛠️ Some algorithms (e.g., Naive Bayes variants) work naturally with discrete features
The cost: information loss. Everything within a bin is treated as identical, so only bin when there's a reason.
14.2 Binning Strategies
| Strategy | How bins are formed | Pros | Cons |
|---|---|---|---|
| Equal-width (uniform) | Same-sized intervals across the range | Simple, easy to explain | Skewed data → some bins nearly empty, one overloaded |
| Equal-frequency (quantile) | Each bin has about the same number of rows | Balanced bins; robust to outliers | Bin widths vary; similar values may be split |
| Custom / domain-based | You choose meaningful cut points | Most interpretable | Needs domain knowledge |
| Clustering-based (k-means) | Bins follow natural groupings | Adapts to structure | Less transparent |
14.3 With pandas
ages = pd.Series([5, 17, 23, 34, 41, 58, 72])
# Equal-width: 3 bins of equal size
pd.cut(ages, bins=3)
# Custom bins with labels
pd.cut(ages, bins=[0, 18, 40, 65, 120],
labels=["child", "young adult", "middle-aged", "senior"],
right=False) # right=False → intervals like [0, 18)
# Equal-frequency: 4 quartile bins
pd.qcut(df["income"], q=4, labels=["Q1", "Q2", "Q3", "Q4"])
| Function | Bins by | Result |
|---|---|---|
pd.cut | Value ranges | Equal-width (or your own edges) |
pd.qcut | Quantiles | Equal-frequency |
14.4 With Scikit-learn
from sklearn.preprocessing import KBinsDiscretizer
kbd = KBinsDiscretizer(n_bins=4, encode="ordinal", strategy="quantile")
# strategy: "uniform" (equal width), "quantile" (equal frequency), "kmeans"
# encode: "ordinal" (bin number), "onehot", "onehot-dense"
X_train_b = kbd.fit_transform(X_train[["age", "income"]]) # bin edges learned from TRAIN
X_test_b = kbd.transform(X_test[["age", "income"]])
kbd.bin_edges_
Being a transformer, it slots into pipelines and avoids leakage.
14.5 Binning Then Encoding
Bins are ordinal categories. You can keep them as integers (bin 0, 1, 2…) or one-hot encode them (encode="onehot") to let a linear model learn a separate effect per bin, a way to capture non-linear shapes.
14.6 Choosing the Number of Bins
- Too few: lose detail (underfitting the feature)
- Too many: noisy, sparse bins (little benefit over the raw number)
- Start with a handful (e.g., 4–10), check the result visually (histogram of the binned feature), and tune
14.7 When Not to Bin
- Tree-based models already find their own split points on raw numbers, so binning rarely helps
- When the precise numeric value matters
- When you can't justify the information loss
15. Data Transformation
Data transformation changes the shape or representation of features (not just the scale) to make patterns easier for a model to learn, or to satisfy modelling assumptions.
15.1 Why Transform?
- Reduce skewness: bring long-tailed data closer to a symmetric (bell-like) shape
- Stabilise variance: so spread doesn't grow with the value
- Linearise relationships: so a linear model fits better
- Reduce the impact of outliers
- Satisfy assumptions of certain models (e.g., roughly normal residuals in linear regression)
15.2 Mathematical Transforms for Skewed Data
Right-skewed data (long tail →) After log transform
count count
█ ▃▆█▆▃
█▅
█▇▃▂▁▁▁▁ (more symmetric)
└──────────► income └──────────► log(income)
| Transform | Formula | Use for | Notes |
|---|---|---|---|
| Log | log(x) or log1p(x) = log(1+x) | Strong right skew (income, prices, counts) | log needs x > 0; log1p handles 0 |
| Square root | √x | Moderate right skew, counts | Needs x ≥ 0 |
| Reciprocal | 1/x | Very strong skew, rates | Reverses order |
| Square / cube | x², x³ | Left skew | |
| Box-Cox | (xλ − 1)/λ (finds best λ) | Positive data; automatically picks the power | Requires x > 0 |
| Yeo-Johnson | Box-Cox extension | Data with zero or negative values | More flexible |
df["income_log"] = np.log1p(df["income"])
df["income_sqrt"] = np.sqrt(df["income"])
# Check improvement
df["income"].skew(), df["income_log"].skew()
15.3 Power and Quantile Transformers (Scikit-learn)
from sklearn.preprocessing import PowerTransformer, QuantileTransformer
pt = PowerTransformer(method="yeo-johnson") # or "box-cox" (positive data only)
X_train_t = pt.fit_transform(X_train) # learns the best λ per feature from TRAIN
X_test_t = pt.transform(X_test) # also standardizes the output by default
qt = QuantileTransformer(output_distribution="normal", n_quantiles=200, random_state=42)
X_train_q = qt.fit_transform(X_train) # forces a normal (or uniform) shape
| Transformer | What it does | Note |
|---|---|---|
PowerTransformer | Finds a power transform making data more Gaussian | Smooth and invertible |
QuantileTransformer | Maps values by rank onto a chosen distribution | Strongly reduces outlier impact; can distort distances between values |
15.4 Transforming the Target
For a skewed numeric target (e.g., house prices), you can model log(price) and convert back afterwards:
from sklearn.compose import TransformedTargetRegressor
from sklearn.linear_model import LinearRegression
model = TransformedTargetRegressor(
regressor=LinearRegression(), func=np.log1p, inverse_func=np.expm1
)
model.fit(X_train, y_train) # trains on log1p(y); predictions come back in original units
15.5 Creating New Features from Existing Ones
Polynomial and interaction features
Let linear models learn curves and combined effects:
from sklearn.preprocessing import PolynomialFeatures
poly = PolynomialFeatures(degree=2, include_bias=False)
poly.fit_transform([[2, 3]]) # [[2, 3, 4, 6, 9]] → x1, x2, x1², x1·x2, x2²
(Feature count grows quickly with degree. Use sparingly and scale afterwards.)
Date/time decomposition
df["year"] = df["date"].dt.year
df["month"] = df["date"].dt.month
df["weekday"] = df["date"].dt.dayofweek
df["is_weekend"] = (df["weekday"] >= 5).astype(int)
Cyclical features
Months, hours and weekdays wrap around (December is next to January). Encoding month as 1–12 puts 12 and 1 far apart. Use sine and cosine:
df["month_sin"] = np.sin(2 * np.pi * df["month"] / 12)
df["month_cos"] = np.cos(2 * np.pi * df["month"] / 12)
Ratios, differences, aggregates
df["price_per_sqft"] = df["price"] / df["area"]
df["age_at_purchase"] = df["purchase_year"] - df["birth_year"]
Custom transforms in a pipeline
from sklearn.preprocessing import FunctionTransformer
log_tf = FunctionTransformer(np.log1p, inverse_func=np.expm1)
15.6 Transformation vs Scaling
| Scaling (Section 12–13) | Transformation (this section) | |
|---|---|---|
| Changes | Range / units | Shape of the distribution (and sometimes feature meaning) |
| Distribution shape | Preserved | Altered |
| Example | Standardize income | Log-transform income |
They're often combined: transform to reduce skew → then scale.
15.7 Cautions
- Check why you're transforming. Look at histograms before and after
- Remember to apply the same transformation (with parameters learned on training data) to test data
- Transformed features are harder to interpret. Keep track of what you did
- Avoid
logof zero or negative numbers (uselog1por Yeo-Johnson) - Tree-based models are generally insensitive to monotonic transforms of features
16. Handling Imbalanced Datasets
A dataset is imbalanced when some classes appear far less often than others.
| Domain | Typical minority class | Typical share |
|---|---|---|
| Fraud detection | Fraudulent transactions | < 1% |
| Medical screening | Disease present | A few % |
| Equipment monitoring | Failure | Rare |
| Spam / churn | Spam / churners | Often 5–20% |
16.1 Why It's a Problem
Models learn to favour the majority class, because predicting it is "usually right."
The accuracy trap: With 99% legitimate and 1% fraudulent transactions, a model that always says "legitimate" gets 99% accuracy, yet catches zero fraud. It's useless.
16.2 Step 1: Use the Right Metrics
| Metric | Why it helps |
|---|---|
| Precision | Of the cases flagged positive, how many truly are? |
| Recall (sensitivity) | Of all true positives, how many did we catch? |
| F1-score | Balance of precision and recall |
| PR-AUC (Precision–Recall AUC) | Summarises the precision/recall trade-off, informative under heavy imbalance |
| ROC-AUC | Ranking quality across thresholds (can look optimistic when imbalance is extreme) |
| Confusion matrix | Shows exactly where mistakes happen |
| Balanced accuracy | Average of recall per class |
from sklearn.metrics import classification_report, confusion_matrix, average_precision_score, balanced_accuracy_score
print(classification_report(y_test, y_pred))
Which to prioritise depends on the cost of errors:
- Missing a real case is costly (cancer, fraud) → favour recall
- False alarms are costly (blocking legitimate customers) → favour precision
16.3 Step 2: Split Carefully
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, stratify=y, random_state=42 # keeps the class ratio in both sets
)
Use stratified splits and stratified cross-validation (StratifiedKFold) so each fold contains minority examples.
16.4 Step 3: Techniques to Balance Learning
A) Data-level: Resampling
Original: ■■■■■■■■■■■■■■■■■■ □□
Undersample: ■■■■ □□ (remove majority examples)
Oversample: ■■■■■■■■■■■■■■■■■■ □□□□□□□□□□□□□□□□□□ (add minority examples)
| Technique | Idea | Pros | Cons |
|---|---|---|---|
| Random undersampling | Remove random majority rows | Fast; smaller data | Throws away information |
| Random oversampling | Duplicate minority rows | No information loss | Can cause overfitting (exact copies) |
| SMOTE | Create synthetic minority samples by interpolating between neighbouring minority points | Less overfitting than duplication | Can create noisy or overlapping samples; numeric features only (variants exist for categorical) |
| Tomek links / ENN (cleaning) | Remove ambiguous majority points near the boundary | Cleaner class boundary | May remove useful data |
| Combined (e.g., SMOTE + cleaning) | Oversample, then clean | Often works well | More complex |
How SMOTE works:
- Pick a minority point.
- Find its k nearest minority neighbours.
- Choose one neighbour at random and create a new point somewhere on the line between them.
# pip install imbalanced-learn
from imblearn.over_sampling import SMOTE, RandomOverSampler
from imblearn.under_sampling import RandomUnderSampler
smote = SMOTE(random_state=42)
X_res, y_res = smote.fit_resample(X_train, y_train) # resample the TRAINING set only!
🚨 Resample the training data ONLY, never the validation/test data. The test set should reflect the real-world class balance. Resampling before splitting leaks synthetic copies of test-related points into training.
B) Algorithm-level: Class weights
Make mistakes on the minority class cost more, with no change to the data:
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import RandomForestClassifier
LogisticRegression(class_weight="balanced")
RandomForestClassifier(class_weight="balanced", random_state=42)
LogisticRegression(class_weight={0: 1, 1: 10}) # custom: minority errors count 10×
"balanced" sets weights inversely proportional to class frequencies. It's often the simplest first thing to try.
C) Threshold moving
Models output probabilities, and the default cut-off is 0.5. For rare positives, lower the threshold to catch more of them:
proba = model.predict_proba(X_test)[:, 1]
y_pred = (proba >= 0.3).astype(int)
Choose the threshold using validation data and the precision–recall trade-off.
D) Other approaches
- Collect more minority data (often the best fix if possible)
- Ensemble methods designed for imbalance (e.g., balanced bagging/boosting variants)
- Anomaly detection framing when the minority class is extremely rare
- Data augmentation for the minority class (e.g., for images)
16.5 Resampling Inside a Pipeline (No Leakage)
from imblearn.pipeline import Pipeline as ImbPipeline
from sklearn.preprocessing import StandardScaler
pipe = ImbPipeline([
("scale", StandardScaler()),
("smote", SMOTE(random_state=42)), # applied only when FITTING, i.e. on training folds
("model", LogisticRegression(max_iter=500)),
])
scores = cross_val_score(pipe, X, y, cv=StratifiedKFold(5), scoring="f1")
Using the imblearn Pipeline ensures SMOTE is applied only to the training portion of each fold.
16.6 Choosing an Approach
| Situation | Try first |
|---|---|
| Mild imbalance | Stratified split + class weights + proper metrics |
| Moderate imbalance, enough data | Class weights, or random undersampling |
| Severe imbalance, small minority | SMOTE (in a pipeline) / class weights / threshold tuning |
| Costs of errors differ a lot | Threshold tuning + cost-sensitive metrics |
| Possible with more data | Collect more minority examples |
17. Sampling Techniques
Sampling means selecting a subset of data from a larger set. We sample because the full dataset may be too large to process, too expensive to collect or label, or just unnecessary.
17.1 Population vs Sample
- Population: the complete group you care about
- Sample: the subset you actually work with
- Goal: a sample that is representative (a "miniature" of the population)
- Sampling frame: the list from which you draw the sample
- Sampling error: natural difference between sample and population (shrinks as sample size grows)
- Sampling bias: systematic distortion (doesn't shrink with size)
Population ──(sampling method)──► Sample ──(analysis/model)──► Conclusions about the population
17.2 Probability Sampling (Random, Every Member Has a Known Chance)
| Method | How it works | Example | Pros | Cons |
|---|---|---|---|---|
| Simple random | Every item equally likely | Draw 1,000 customers at random | Unbiased, simple | May under-represent small groups |
| Systematic | Take every k-th item after a random start | Every 10th record | Easy, evenly spread | Problem if the data has a periodic pattern matching k |
| Stratified | Split into groups (strata), sample from each | Sample proportionally from each region or class | Guarantees representation of every group; more precise | Needs known group labels |
| Cluster | Randomly pick whole groups, use all members | Choose 5 schools, survey all students | Cheaper when groups are natural | Higher error if clusters differ from each other |
| Multistage | Sample in stages (clusters, then individuals within) | Cities → neighbourhoods → households | Practical for huge populations | More complex |
# Simple random sample
df.sample(n=1000, random_state=42) # exactly 1,000 rows
df.sample(frac=0.1, random_state=42) # 10% of rows
# Systematic sample
df.iloc[::10] # every 10th row
# Stratified sample (keep proportions of a column)
df.groupby("region", group_keys=False).apply(
lambda g: g.sample(frac=0.1, random_state=42)
)
# or while splitting data:
train_test_split(X, y, test_size=0.2, stratify=y, random_state=42)
# Cluster sample
chosen = np.random.default_rng(42).choice(df["school"].unique(), size=5, replace=False)
sample = df[df["school"].isin(chosen)]
17.3 Non-Probability Sampling (Not Random, So Bias Is a Risk)
| Method | How it works | Typical use | Risk |
|---|---|---|---|
| Convenience | Whoever is easiest to reach | Quick pilot surveys | Highly biased |
| Quota | Fill fixed quotas per group, non-randomly | Market research | Selection within the quota is non-random |
| Purposive (judgement) | Researcher picks specific cases | Expert panels, rare cases | Subjective |
| Snowball | Participants recruit others | Hard-to-reach groups | Network bias |
Fine for exploration, but be cautious about generalising from them.
17.4 Resampling Techniques
Different from the above: here you re-draw from the data you already have.
Bootstrap
Sample with replacement to create many "new" datasets of the same size; useful for estimating the uncertainty of a statistic or model, and the basis of bagging.
rng = np.random.default_rng(42)
boot_means = [rng.choice(data, size=len(data), replace=True).mean() for _ in range(1000)]
np.percentile(boot_means, [2.5, 97.5]) # rough 95% interval for the mean
With replacement, each bootstrap sample leaves out roughly 36.8% of the original rows (the out-of-bag samples), which can be used for validation.
With vs without replacement
| Without replacement | With replacement | |
|---|---|---|
| Same item picked twice? | No | Yes |
| Typical use | Standard samples, splits | Bootstrap, oversampling |
Class-balancing resampling
Over/under-sampling (see Section 16).
Cross-validation folds
A systematic form of resampling used to evaluate models (see Section 18).
17.5 Sampling Special Cases
- Time-ordered data: don't randomly shuffle if the future must not influence the past. Sample or split by time
- Streaming data: reservoir sampling keeps a fixed-size random sample while data keeps arriving
- Grouped data: sample by group (e.g., patients), not by row, to keep each group intact
17.6 How Big Should the Sample Be?
There's no single number. It depends on:
- The variability of the data (more variation → more samples)
- The number of features and model complexity
- The rarity of the classes you care about (you need enough minority examples)
- Desired precision (smaller margin of error requires more data)
Rule of thumb: more data helps, but representativeness matters more than raw size. A huge biased sample is still biased. Plot a learning curve (performance vs training size) to see whether more data would help.
17.7 Sampling Summary
Need every group represented? → Stratified
Data naturally in clusters? → Cluster
Quick and simple, no group structure? → Simple random
Regular list, no hidden pattern? → Systematic
Estimating uncertainty? → Bootstrap
Class imbalance? → Over / under-sampling (training set only)
Time series? → Sample/split chronologically
18. Train, Validation and Test Splits
A model that is judged on the same data it learned from will look brilliant, because it can memorise. To measure real-world performance, we hold data back.
📚 Analogy: The training set is the textbook and practice problems. The validation set is a mock exam you use to adjust your study strategy. The test set is the final exam you take once, with questions you've never seen.
18.1 The Three Sets
| Set | Purpose | Used for | Touched how often? |
|---|---|---|---|
| Training | The model learns from it | Fitting parameters | Constantly |
| Validation | Tune and compare models | Hyperparameters, model selection, early stopping | Many times |
| Test | Final, unbiased performance estimate | Reporting how it will do in the real world | Once, at the very end |
┌───────────────────────────── Full dataset ─────────────────────────────┐
│ Training (≈60–80%) │ Validation (≈10–20%) │ Test (≈10–20%) │
└───────────────────────────────────────────────────────────────────────┘
fit the model tune & choose final report
🚨 The test set is sacred. If you keep peeking at it and adjusting based on the result, it effectively becomes a validation set, and your final estimate becomes over-optimistic.
18.2 Typical Ratios
| Dataset size | Common split |
|---|---|
| Small (thousands) | 70 / 15 / 15 or 80 / 20 with cross-validation |
| Medium | 60 / 20 / 20 |
| Very large (millions) | 98 / 1 / 1 can be enough (1% is still a lot of rows) |
Make sure each set is large enough to give a stable estimate, especially for the minority class.
18.3 A Simple Train/Test Split
from sklearn.model_selection import train_test_split
X_train, X_test, y_train, y_test = train_test_split(
X, y,
test_size=0.2, # 20% held out
random_state=42, # reproducible
shuffle=True, # default; set False for time-ordered data
stratify=y, # keep class proportions (classification)
)
18.4 Train / Validation / Test (Three-Way Split)
Split twice:
# Step 1: carve out the test set (20%)
X_temp, X_test, y_temp, y_test = train_test_split(
X, y, test_size=0.20, stratify=y, random_state=42
)
# Step 2: split the remainder into train (60% of total) and validation (20% of total)
X_train, X_val, y_train, y_val = train_test_split(
X_temp, y_temp, test_size=0.25, stratify=y_temp, random_state=42 # 0.25 × 80% = 20%
)
# Result: 60% train / 20% validation / 20% test
18.5 Choosing the Right Splitting Strategy
| Data situation | Strategy | Tool |
|---|---|---|
| General tabular, independent rows | Random split | train_test_split |
| Classification, imbalanced | Stratified split | stratify=y |
| Several rows per entity (patient, user) | Group-based split (an entity is entirely in one set) | GroupShuffleSplit, GroupKFold |
| Time series / future prediction | Chronological split (train on past, test on future) | Sort by time, or TimeSeriesSplit |
Group split
Prevents the same patient/customer appearing in both train and test:
from sklearn.model_selection import GroupShuffleSplit
gss = GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=42)
train_idx, test_idx = next(gss.split(X, y, groups=df["patient_id"]))
X_train, X_test = X.iloc[train_idx], X.iloc[test_idx]
y_train, y_test = y.iloc[train_idx], y.iloc[test_idx]
Time-based split
df = df.sort_values("date")
cutoff = int(len(df) * 0.8)
train, test = df.iloc[:cutoff], df.iloc[cutoff:] # past → train, future → test
Using a random split on time series lets the model "see the future" and gives unrealistically good scores.
18.6 Cross-Validation (CV)
When data is limited, a single validation split can be unreliable. K-fold cross-validation rotates the validation role among k parts:
Fold 1: [VAL ][train][train][train][train]
Fold 2: [train][VAL ][train][train][train]
Fold 3: [train][train][VAL ][train][train]
Fold 4: [train][train][train][VAL ][train]
Fold 5: [train][train][train][train][VAL ]
→ average the 5 scores
from sklearn.model_selection import cross_val_score, KFold, StratifiedKFold
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
scores = cross_val_score(pipeline, X_train, y_train, cv=cv, scoring="accuracy")
print(scores.mean(), scores.std())
| Variant | Use when |
|---|---|
| KFold | Standard regression/classification |
| StratifiedKFold | Classification, especially imbalanced |
| GroupKFold | Grouped data |
| TimeSeriesSplit | Time-ordered data (always trains on earlier data, validates on later) |
| Leave-One-Out | Tiny datasets (expensive) |
Typical workflow with CV:
- Hold out a test set first.
- Use cross-validation on the training set for model selection and tuning (so you don't need a separate validation set).
- Retrain the best model on the whole training set.
- Evaluate once on the test set.
18.7 Reading the Results
| Observation | Likely meaning |
|---|---|
| Train score ≈ validation score, both high | Good fit 🎉 |
| Train high, validation much lower | Overfitting |
| Both low | Underfitting (or poor features/data) |
| Validation good, test much worse | Over-tuning to the validation set, or leakage |
18.8 Rules of Thumb
- ✅ Split first, before computing any statistics or fitting any transformer
- ✅ Always set
random_statefor reproducibility - ✅ Make the test set resemble real future data
- ✅ Check that the class balance is similar across the sets
- ❌ Don't tune on the test set
- ❌ Don't let duplicates or related rows straddle the split
19. Data Leakage and Prevention
Data leakage is when information from outside the training data, usually from the test set or the future, sneaks into model training. The model then looks great in testing but fails in the real world.
🎓 Analogy: A student who obtains the exam questions in advance scores 100%, but has learned nothing about the subject.
19.1 Why It's So Dangerous
- Results look too good to be true and often are
- It's silent: no error message
- The failure only appears after deployment, which is expensive
19.2 The Main Types
1) Train–Test Contamination (preprocessing leakage)
Using the whole dataset to compute statistics for scaling, imputing, encoding, or feature selection before splitting.
# ❌ LEAKY: scaler learns mean/std from ALL rows, including the future test rows
scaler = StandardScaler()
X_scaled = scaler.fit_transform(X)
X_train, X_test, y_train, y_test = train_test_split(X_scaled, y)
# ✅ CORRECT: learn from training data only
X_train, X_test, y_train, y_test = train_test_split(X, y)
scaler = StandardScaler().fit(X_train)
X_train_s = scaler.transform(X_train)
X_test_s = scaler.transform(X_test)
Same applies to: imputers, encoders (especially target encoding), outlier limits, PCA, SMOTE, feature selection.
2) Target Leakage
A feature contains information that wouldn't be available at prediction time, or is a consequence of the target.
| Task | Leaky feature | Why it's a problem |
|---|---|---|
| Predict loan default | collections_status | Only exists after someone defaults |
| Predict hospital readmission | discharge_notes_after_followup | Written after the outcome |
| Predict customer churn | account_closed_date | Is the target, restated |
| Predict fraud | chargeback_amount | Known only after fraud is confirmed |
| Predict a sale | refund_issued | Happens after the sale |
Ask for every feature: "Will I truly have this value at the moment I make a prediction?"
3) Temporal Leakage (Using the Future)
- Randomly splitting time-ordered data so future rows land in training
- Features computed using future information (e.g., a "30-day average" that includes days after the prediction date)
4) Duplicate / Overlap Leakage
The same or near-identical record appears in both train and test. The model simply memorises it.
5) Group Leakage
Related rows (same patient, user, device, document) are split across train and test. The model learns the entity, not the general pattern.
6) Leakage Through Hyperparameter Tuning
Repeatedly adjusting the model based on test-set performance. The test set silently becomes part of training.
7) Leakage in Resampling/Augmentation
Oversampling (e.g., SMOTE) or augmenting data before splitting, so near-copies of the same record appear on both sides.
19.3 Warning Signs 🚩
- Performance is suspiciously high (e.g., 99% on a hard problem)
- One feature has an enormous importance or an almost perfect correlation with the target
- Big gap between test performance and real-world performance
- Performance collapses when a particular feature is removed
- A feature's values are only filled in for one class
19.4 Prevention Checklist ✅
- Split first. Before fitting any preprocessing step
- Fit on train only.
fit/fit_transformon training data;transformon validation/test - Use pipelines, so every step is learned within the training portion of each split/fold (see Section 20)
- Cross-validate the whole pipeline, not just the model
- Examine every feature for availability at prediction time (consult domain experts)
- Respect time: use chronological splits and "as-of" feature calculations for temporal data
- Deduplicate before splitting; split by group when rows are related
- Resample only the training set (and inside the pipeline)
- Keep the test set locked until the final evaluation
- Be suspicious of great results. Investigate before celebrating
19.5 Using a Pipeline to Prevent Preprocessing Leakage
from sklearn.pipeline import make_pipeline
from sklearn.model_selection import cross_val_score
pipe = make_pipeline(SimpleImputer(strategy="median"), StandardScaler(), LogisticRegression())
# During cross-validation, the imputer and scaler are re-fitted on each fold's TRAINING part only.
scores = cross_val_score(pipe, X, y, cv=5)
Compare with the leaky way (scaling everything first, then cross-validating), where each validation fold has "influenced" the scaler.
19.6 Leakage Detection Quick Test
# Which single feature predicts the target "too well"?
for col in X.select_dtypes("number"):
corr = np.abs(np.corrcoef(X[col].fillna(0), y)[0, 1])
if corr > 0.9:
print("Suspicious:", col, round(corr, 3))
A near-perfect predictor deserves a "how could I possibly know this in advance?" question.
20. Data Pipelines Using Scikit-learn
A pipeline chains preprocessing steps and a model into one object. You call fit() once and predict() once, and everything happens in the right order, with no leakage.
20.1 Why Use Pipelines?
| Benefit | Explanation |
|---|---|
| 🔒 Prevents leakage | Steps are fitted on training data only (including inside cross-validation) |
| 🔁 Reproducible | Same steps applied identically to new data |
| 🧼 Cleaner code | One object instead of many loose variables |
| 🎛️ Easy tuning | Tune preprocessing and model hyperparameters together |
| 🚀 Easy deployment | Save one object; new raw data goes straight in |
20.2 Building Blocks
| Tool | Purpose |
|---|---|
Pipeline / make_pipeline | Chain steps in sequence (transformers, then a final model) |
ColumnTransformer | Apply different preprocessing to different columns |
make_column_selector | Pick columns by dtype/name automatically |
FunctionTransformer | Wrap any function as a transformer |
| Custom transformer | Your own class (BaseEstimator + TransformerMixin) |
FeatureUnion | Run transformers in parallel and join their outputs |
20.3 A Basic Pipeline
from sklearn.pipeline import Pipeline
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
pipe = Pipeline(steps=[
("imputer", SimpleImputer(strategy="median")),
("scaler", StandardScaler()),
("model", LogisticRegression(max_iter=500)),
])
pipe.fit(X_train, y_train) # imputer.fit_transform → scaler.fit_transform → model.fit
y_pred = pipe.predict(X_test) # imputer.transform → scaler.transform → model.predict
pipe.score(X_test, y_test)
Shortcut: make_pipeline names the steps automatically:
from sklearn.pipeline import make_pipeline
pipe = make_pipeline(SimpleImputer(strategy="median"), StandardScaler(), LogisticRegression())
Rules: every step except the last must be a transformer (has fit / transform). The last can be a transformer or a model.
Accessing parts:
pipe.named_steps["scaler"].mean_
pipe[0] # first step
pipe[:-1] # everything except the model (just the preprocessing)
20.4 ColumnTransformer: Different Treatment per Column
Real tables mix numeric and categorical columns. Each needs its own steps.
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import OneHotEncoder, OrdinalEncoder
numeric_features = ["age", "income"]
categorical_features = ["city", "device"]
ordinal_features = ["education"]
numeric_pipe = Pipeline([
("impute", SimpleImputer(strategy="median")),
("scale", StandardScaler()),
])
categorical_pipe = Pipeline([
("impute", SimpleImputer(strategy="most_frequent")),
("onehot", OneHotEncoder(handle_unknown="ignore")),
])
ordinal_pipe = Pipeline([
("impute", SimpleImputer(strategy="most_frequent")),
("ordinal", OrdinalEncoder(categories=[["school", "graduate", "postgraduate"]])),
])
preprocessor = ColumnTransformer(
transformers=[
("num", numeric_pipe, numeric_features),
("cat", categorical_pipe, categorical_features),
("ord", ordinal_pipe, ordinal_features),
],
remainder="drop", # what to do with unlisted columns: "drop" (default) or "passthrough"
)
Selecting columns automatically
from sklearn.compose import make_column_selector as selector
preprocessor = ColumnTransformer([
("num", numeric_pipe, selector(dtype_include="number")),
("cat", categorical_pipe, selector(dtype_exclude="number")),
])
20.5 A Complete End-to-End Pipeline
import pandas as pd
from sklearn.model_selection import train_test_split
from sklearn.ensemble import RandomForestClassifier
X = df.drop(columns=["target"])
y = df["target"]
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, stratify=y, random_state=42
)
full_pipeline = Pipeline([
("preprocess", preprocessor),
("model", RandomForestClassifier(class_weight="balanced", random_state=42)),
])
full_pipeline.fit(X_train, y_train) # raw DataFrame in...
print(full_pipeline.score(X_test, y_test)) # ...predictions out
full_pipeline.predict(new_raw_dataframe) # new raw data goes straight in
20.6 Cross-Validation and Tuning with Pipelines
from sklearn.model_selection import cross_val_score, GridSearchCV
# Leak-free cross-validation: preprocessing is re-fitted inside each fold
cross_val_score(full_pipeline, X_train, y_train, cv=5, scoring="f1")
# Tune preprocessing AND model together: parameter names use stepname__param
param_grid = {
"preprocess__num__impute__strategy": ["mean", "median"],
"model__n_estimators": [100, 300],
"model__max_depth": [None, 10],
}
search = GridSearchCV(full_pipeline, param_grid, cv=5, scoring="f1")
search.fit(X_train, y_train)
search.best_params_
search.best_estimator_.score(X_test, y_test) # final one-time test evaluation
Double underscores __ dive into nested steps: preprocess → num → impute → strategy.
20.7 Custom Transformers
Make your own step by subclassing BaseEstimator and TransformerMixin:
from sklearn.base import BaseEstimator, TransformerMixin
class OutlierCapper(BaseEstimator, TransformerMixin):
"""Clip each feature to IQR-based limits learned from the training data."""
def __init__(self, factor=1.5):
self.factor = factor
def fit(self, X, y=None):
X = np.asarray(X, dtype=float)
q1, q3 = np.percentile(X, [25, 75], axis=0)
iqr = q3 - q1
self.lower_ = q1 - self.factor * iqr # learned values end with "_"
self.upper_ = q3 + self.factor * iqr
return self # always return self
def transform(self, X):
return np.clip(np.asarray(X, dtype=float), self.lower_, self.upper_)
numeric_pipe = Pipeline([
("impute", SimpleImputer(strategy="median")),
("cap", OutlierCapper(factor=1.5)),
("scale", StandardScaler()),
])
Pattern: learn from training data in fit, apply in transform. That's exactly what makes it leak-free.
Quick function-based step
from sklearn.preprocessing import FunctionTransformer
log_step = FunctionTransformer(np.log1p, feature_names_out="one-to-one")
20.8 Pipelines Including Transformations from This Guide
numeric_skewed = ["income", "spend"]
numeric_normal = ["age"]
preprocessor = ColumnTransformer([
("skewed", Pipeline([
("impute", SimpleImputer(strategy="median")),
("power", PowerTransformer(method="yeo-johnson")), # reduce skew, then standardises
]), numeric_skewed),
("normal", Pipeline([
("impute", SimpleImputer(strategy="mean")),
("scale", StandardScaler()),
]), numeric_normal),
("binned", KBinsDiscretizer(n_bins=5, encode="onehot", strategy="quantile"), ["tenure"]),
("cat", OneHotEncoder(handle_unknown="ignore"), ["city"]),
])
20.9 Handy Features
# 1. See the pipeline as a diagram (in notebooks)
from sklearn import set_config
set_config(display="diagram")
full_pipeline
# 2. Get output column names
preprocessor.get_feature_names_out()
# 3. Get a DataFrame out of transformers (Scikit-learn 1.2+)
preprocessor.set_output(transform="pandas")
X_train_df = preprocessor.fit_transform(X_train)
# 4. Inspect all parameters
full_pipeline.get_params()
# 5. Save and reload the whole thing
import joblib
joblib.dump(full_pipeline, "pipeline.joblib")
loaded = joblib.load("pipeline.joblib")
loaded.predict(new_data)
20.10 Pipelines with Imbalanced Data
Use imblearn.pipeline.Pipeline when the pipeline contains a resampler such as SMOTE (see Section 16.5). The Scikit-learn Pipeline doesn't support steps that change the number of rows.
20.11 Pipeline Pitfalls
| Pitfall | Fix |
|---|---|
| Fitting preprocessing outside the pipeline | Put every learned step inside the pipeline |
Wrong column names in ColumnTransformer | Check X.columns; use selectors |
| Unseen categories at prediction time | OneHotEncoder(handle_unknown="ignore") |
Forgetting remainder="passthrough" and losing columns | Set remainder deliberately |
| Doing target encoding or resampling outside | Keep them inside (and fitted on training data only) |
| Pipelines hiding what happens | Inspect intermediate outputs with pipe[:-1].transform(X) |
20.12 The Ideal Workflow
1. Load raw data
2. Do EDA and quality checks (on training data)
3. Split off the TEST set (stratified / grouped / time-based as appropriate)
4. Define preprocessing in a ColumnTransformer
5. Wrap preprocessing + model in a Pipeline
6. Cross-validate / tune the whole pipeline on the TRAINING set
7. Fit the best pipeline on all training data
8. Evaluate ONCE on the test set
9. Save the pipeline and deploy it
21. Quick Revision Cheat Sheet
🔷 Part A: Understanding Data
| Topic | Remember |
|---|---|
| Data types | Numerical (discrete/continuous), Categorical (nominal/ordinal), Date, Text. IDs and zip codes are categories |
| Formats | CSV (universal), JSON (nested), Parquet (fast and typed), SQL (databases) |
| Collection | Primary vs secondary; databases, APIs, scraping, sensors; document it and check bias and legality |
| Structure | Structured = tables; Semi = JSON/XML (flatten with json_normalize); Unstructured = text/images (extract features) |
| EDA | Overview → univariate → bivariate → multivariate → target. Look for skew, outliers, missingness, correlations |
| Quality | Completeness, accuracy, consistency, uniqueness, validity, timeliness |
🔷 Part B: Cleaning
| Topic | Remember |
|---|---|
| Missing | MCAR / MAR / MNAR. Delete (few, random) or impute (median / mode / KNN / iterative). Add indicator columns. Fit imputers on train only |
| Duplicates | duplicated(), drop_duplicates(subset=...). Normalise text first. Dedupe before splitting |
| Outliers | Detect: IQR (1.5×), z-score (` |
🔷 Part C: Transforming Features
| Topic | Remember |
|---|---|
| Label | For the target (LabelEncoder) |
| One-hot | Nominal, low cardinality. Use OneHotEncoder(handle_unknown="ignore"). Avoid dummy-variable trap with drop="first" for linear models |
| Ordinal | Ordered categories. Specify the order |
| Target encoding | High cardinality. Use smoothing + cross-fitting to avoid leakage |
| Scaling | Needed by distance/gradient/regularised models and PCA; not by trees |
| Min-Max | (x−min)/(max−min) → [0,1]; outlier-sensitive |
| Standardize | (x−μ)/σ → mean 0, std 1; good default |
| Robust | (x−median)/IQR; for outliers |
| Binning | pd.cut (equal-width), pd.qcut (equal-frequency), KBinsDiscretizer; loses information |
| Transform | log1p, sqrt, Box-Cox (positive), Yeo-Johnson (any); fix skew |
🔷 Part D: Modelling Prep
| Topic | Remember |
|---|---|
| Imbalance | Don't trust accuracy. Use precision / recall / F1 / PR-AUC. Stratify. Try class weights, SMOTE (training only), threshold tuning |
| Sampling | Random, systematic, stratified, cluster; bootstrap (with replacement) |
| Splits | Train (learn) / Validation (tune) / Test (final, once). Stratified, grouped, or chronological as needed. CV for small data |
| Leakage | Split first; fit transformers on train only; no future info; no duplicates across splits; use pipelines |
| Pipelines | Pipeline + ColumnTransformer; tune with stepname__param; save with joblib |
🔷 Which Scaler / Encoder / Imputer? Fast Table
| Situation | Choice |
|---|---|
| Numeric, skewed, missing | SimpleImputer(median) → PowerTransformer / log1p → StandardScaler |
| Numeric with outliers | RobustScaler |
| Nominal, few categories | OneHotEncoder |
| Nominal, many categories | TargetEncoder (with CV/smoothing), frequency, or group rare values |
| Ordered categories | OrdinalEncoder(categories=[...]) |
| Target labels | LabelEncoder |
| Sparse data | MaxAbsScaler |
🔷 The Golden Rules
1. SPLIT FIRST, then fit any transformer on TRAIN only.
2. Never touch the test set until the very end.
3. Investigate outliers and missing values before deleting.
4. Keep every learned step inside a PIPELINE.
5. If results look too good to be true, suspect LEAKAGE.
✅ Final Takeaways
- Good data beats fancy models. Collection quality sets the ceiling.
- EDA and quality checks tell you what to fix before you fix it.
- Missing values, duplicates and outliers each need a deliberate, justified decision, not an automatic one.
- Encoding must match the category type (nominal, ordinal, target), and scaling should match the algorithm and the outliers.
- Imbalanced data needs better metrics and smarter training, not just more accuracy.
- Splitting correctly (stratified, grouped, or time-based) is what makes your evaluation honest.
- Data leakage is the silent killer. Pipelines are the cure.
#Exploratory Data Analysis (EDA) and Visualization#
Think of EDA as getting to know your data before you try to model it. You wouldn't hire someone without reading their resume, and you shouldn't train a model without understanding your dataset. EDA is how you "read the resume": what's in it, what's odd, what's connected, and what needs fixing.
These notes walk through the core ideas of EDA, from the basics to the more advanced checks, with small code snippets you can run yourself.
Table of Contents
- What is EDA and why does it matter?
- Univariate, Bivariate and Multivariate Analysis
- Histograms, Box Plots and Violin Plots
- Scatter Plots and Pair Plots
- Correlation Matrices and Heatmaps
- Distribution Analysis
- Skewness and Kurtosis
- Class Distribution Analysis
- Detecting Relationships Between Features
- Detecting Multicollinearity
- Data Profiling and Summary Statistics
- Quick Cheat Sheet
0. What is EDA and why does it matter?
Exploratory Data Analysis (EDA) is the process of examining a dataset using summary statistics and visualizations to understand its structure, spot patterns, find anomalies, and check assumptions, before any formal modelling.
What EDA helps you answer:
- What does each feature look like (range, shape, spread)?
- Are there outliers or strange values?
- Which features are related to each other, and to the target?
- Are the classes balanced?
- Are some features redundant (multicollinear)?
- Do the data meet the assumptions of the model I want to use?
Setup used in all examples
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
from scipy import stats
sns.set_theme(style="whitegrid")
# Example: seaborn's built-in "tips" dataset
df = sns.load_dataset("tips")
df.head()
Tip: Every plot and statistic in these notes answers a question. Always ask yourself "what am I trying to find out?" before you make a chart.
1. Univariate, Bivariate and Multivariate Analysis
The words just tell you how many variables you look at at once.
| Type | Variables | Question it answers | Typical tools |
|---|---|---|---|
| Univariate | 1 | "What does this one feature look like?" | Histogram, box plot, count plot, mean/median/std |
| Bivariate | 2 | "How do these two features relate?" | Scatter plot, grouped box plot, crosstab, correlation |
| Multivariate | 3 or more | "How do many features interact together?" | Pair plot, heatmap, colour/size/facet encodings |
1.1 Univariate analysis
You study each column on its own.
- Numerical features: look at central tendency (mean, median), spread (std, IQR, range), shape (skewness, kurtosis) and outliers.
- Categorical features: look at counts, proportions, and the number of unique categories.
# Numerical
df["total_bill"].describe()
sns.histplot(df["total_bill"], kde=True)
# Categorical
df["day"].value_counts()
df["day"].value_counts(normalize=True) # proportions
sns.countplot(data=df, x="day")
1.2 Bivariate analysis
You study the relationship between two variables. The right tool depends on the data types:
| Feature 1 | Feature 2 | Good choices |
|---|---|---|
| Numerical | Numerical | Scatter plot, correlation coefficient |
| Numerical | Categorical | Box plot / violin plot per category, group means |
| Categorical | Categorical | Crosstab, stacked/grouped bar chart, chi-square test |
# Num vs Num
sns.scatterplot(data=df, x="total_bill", y="tip")
# Num vs Cat
sns.boxplot(data=df, x="day", y="total_bill")
# Cat vs Cat
pd.crosstab(df["day"], df["smoker"], normalize="index")
1.3 Multivariate analysis
You look at three or more variables at the same time. Ways to do it:
- Add a third variable through colour (
hue), size, or marker style. - Use facets / small multiples (one subplot per category).
- Use pair plots and correlation heatmaps to see all numerical pairs at once.
# 3 variables in one plot: x, y, and colour
sns.scatterplot(data=df, x="total_bill", y="tip", hue="time", size="size")
# Facets
sns.relplot(data=df, x="total_bill", y="tip", col="day", hue="smoker", kind="scatter")
Why it matters: two features can look unrelated on their own but show a strong pattern once a third variable is taken into account (a hidden confounder). Multivariate views catch what bivariate views miss.
2. Histograms, Box Plots and Violin Plots
These are the three workhorse plots for understanding a single numerical feature (and comparing it across groups).
2.1 Histogram
A histogram splits the range of a feature into bins and counts how many values fall in each bin.
What it reveals:
- Overall shape: symmetric, skewed, bell-shaped, uniform
- Modality: one peak (unimodal), two peaks (bimodal), more (multimodal)
- Spread, gaps and unusual values
Watch out for bin size. Too few bins hide detail; too many bins make the plot noisy.
fig, axes = plt.subplots(1, 3, figsize=(15, 4))
sns.histplot(df["total_bill"], bins=5, ax=axes[0]).set_title("Too few bins")
sns.histplot(df["total_bill"], bins=20, kde=True, ax=axes[1]).set_title("Reasonable")
sns.histplot(df["total_bill"], bins=100, ax=axes[2]).set_title("Too many bins")
plt.show()
A KDE (Kernel Density Estimate) curve is a smooth version of the histogram. Adding
kde=Trueoverlays it.
Reading the shapes:
| Shape | What it looks like | Meaning |
|---|---|---|
| Symmetric / bell | Peak in the middle, equal tails | Mean ≈ median |
| Right-skewed | Long tail on the right | Mean > median |
| Left-skewed | Long tail on the left | Mean < median |
| Bimodal | Two peaks | Probably two sub-groups mixed together |
| Uniform | Flat | All values about equally likely |
2.2 Box plot (box-and-whisker plot)
A box plot summarizes a distribution using the five-number summary:
min Q1 median Q3 max
|-------[=========|=========]---------|
whisker box (IQR) whisker
- Box: from Q1 (25th percentile) to Q3 (75th percentile). Its length is the IQR = Q3 − Q1.
- Line inside the box: the median.
- Whiskers: extend to the furthest points within 1.5 × IQR of the box edges.
- Dots beyond the whiskers: potential outliers.
Outlier rule (Tukey's fences):
Lower fence = Q1 − 1.5 × IQR
Upper fence = Q3 + 1.5 × IQR
sns.boxplot(data=df, x="day", y="total_bill")
# Compute outliers by hand
Q1, Q3 = df["total_bill"].quantile([0.25, 0.75])
IQR = Q3 - Q1
outliers = df[(df["total_bill"] < Q1 - 1.5*IQR) | (df["total_bill"] > Q3 + 1.5*IQR)]
Strengths: compact, great for comparing many groups side by side, highlights outliers. Weakness: it hides the shape. Two very different distributions (e.g. unimodal vs bimodal) can produce the same box plot.
2.3 Violin plot
A violin plot = box plot + mirrored KDE. The width of the "violin" at any height shows how many data points are around that value.
sns.violinplot(data=df, x="day", y="total_bill", inner="box")
# Split by a second category
sns.violinplot(data=df, x="day", y="total_bill", hue="sex", split=True)
Strengths: shows the full distribution shape (including multiple peaks) and summary stats. Weakness: a bit harder to read for beginners, and the KDE can look misleading with very small samples.
2.4 Which one should I use?
| Goal | Best plot |
|---|---|
| See the shape of one feature | Histogram (+ KDE) |
| Compare many groups quickly, spot outliers | Box plot |
| Compare groups and see shape differences | Violin plot |
3. Scatter Plots and Pair Plots
3.1 Scatter plot
Each point is one observation, with its x-value and y-value coming from two numerical features. It is the first thing you should draw to see how two numbers relate.
What to look for:
| Pattern | Meaning |
|---|---|
| Points rise from left to right | Positive relationship |
| Points fall from left to right | Negative relationship |
| Random cloud | No relationship |
| Straight band | Linear relationship |
| Curve (U-shape, exponential...) | Non-linear relationship |
| Tight vs. wide band | Strong vs. weak relationship |
| Funnel shape | Changing variance (heteroscedasticity) |
| Distant isolated points | Outliers |
| Separate clumps | Hidden clusters / groups |
sns.scatterplot(data=df, x="total_bill", y="tip", hue="time")
# Add a trend line
sns.regplot(data=df, x="total_bill", y="tip")
Common problem: overplotting. With thousands of points, dots pile up and hide the density. Fixes:
- Lower point opacity:
alpha=0.3 - Use hexbin or 2D density plots:
sns.kdeplot(x=..., y=...)orplt.hexbin(...) - Plot a random sample
3.2 Pair plot (scatter-plot matrix)
A pair plot draws a grid of scatter plots for every pair of numerical features. The diagonal shows each feature's own distribution (histogram or KDE).
sns.pairplot(df, hue="time", diag_kind="kde", corner=True)
Layout:
total_bill tip size
total_bill [dist]
tip [scatter] [dist]
size [scatter] [scatter] [dist]
What it gives you in one glance:
- Every pairwise relationship
- Every feature's distribution
- Group separation (when you colour by a category such as the target class)
- Outliers and non-linear patterns
Limits: with many features (say 15+) the grid becomes unreadable. Use it on a selected subset of important features, or switch to a correlation heatmap first to decide which pairs deserve attention.
4. Correlation Matrices and Heatmaps
4.1 What is correlation?
A correlation coefficient is a single number between −1 and +1 that measures the strength and direction of the relationship between two numerical variables.
| Value | Interpretation |
|---|---|
| +1 | Perfect positive relationship |
| 0 | No (linear) relationship |
| −1 | Perfect negative relationship |
Rule-of-thumb strength (absolute value):
| |r| | Strength |
|---|---|
| 0.00 – 0.19 | Very weak |
| 0.20 – 0.39 | Weak |
| 0.40 – 0.59 | Moderate |
| 0.60 – 0.79 | Strong |
| 0.80 – 1.00 | Very strong |
These are conventions, not laws. What counts as "strong" depends on your field.
4.2 Types of correlation
| Method | Measures | Use when |
|---|---|---|
| Pearson | Linear relationship | Both features numeric, roughly linear, no major outliers |
| Spearman | Monotonic relationship (based on ranks) | Non-linear but consistently rising/falling, skewed data, outliers, ordinal data |
| Kendall's tau | Monotonic, based on concordant/discordant pairs | Small samples, many ties |
num_df = df.select_dtypes(include="number")
num_df.corr(method="pearson")
num_df.corr(method="spearman")
num_df.corr(method="kendall")
4.3 Correlation matrix
A table where cell (i, j) holds the correlation between feature i and feature j.
- The diagonal is always 1 (a feature with itself).
- It is symmetric (corr(A, B) = corr(B, A)), so half of it is redundant.
4.4 Heatmap
A heatmap colours each cell of the matrix, so patterns pop out instantly.
corr = num_df.corr()
plt.figure(figsize=(7, 5))
sns.heatmap(
corr,
annot=True, # print numbers in cells
fmt=".2f",
cmap="coolwarm", # diverging colormap: red = +, blue = -
vmin=-1, vmax=1, # fix the scale so colours are comparable
center=0,
square=True,
)
plt.title("Correlation Heatmap")
plt.show()
Cleaner version: hide the redundant upper triangle
mask = np.triu(np.ones_like(corr, dtype=bool))
sns.heatmap(corr, mask=mask, annot=True, fmt=".2f", cmap="coolwarm", vmin=-1, vmax=1, center=0)
Best practices:
- Use a diverging colormap centred at 0.
- Fix
vmin=-1, vmax=1. - Annotate the numbers if there aren't too many features.
- For many features, drop annotations or use
sns.clustermap(corr)to group similar features together.
4.5 Important warnings
- Correlation ≠ causation. Ice cream sales and drowning both rise in summer; one doesn't cause the other.
- Pearson only sees linear relationships. A perfect U-shaped relationship can have a correlation near 0. Always pair the number with a scatter plot.
- Outliers can distort Pearson dramatically (up or down).
- Always check the plot, not just the coefficient. (See Anscombe's quartet: four very different datasets with nearly identical correlation values.)
5. Distribution Analysis
Distribution analysis asks: "How are the values of this feature spread out?" Knowing the distribution tells you which statistics are meaningful, whether transformations might help, and whether your model's assumptions hold.
5.1 Common distributions you'll meet
| Distribution | Shape / description | Typical examples |
|---|---|---|
| Normal (Gaussian) | Symmetric bell curve | Heights, measurement errors |
| Uniform | Flat, all values equally likely | Random IDs, dice |
| Right-skewed (e.g. log-normal, exponential) | Many small values, a few huge ones | Income, house prices, wait times |
| Left-skewed | Many large values, a few tiny ones | Exam scores with a ceiling, age at retirement |
| Bimodal / multimodal | Two or more peaks | Mixed populations (e.g. height of men and women combined) |
| Binomial / Poisson | Discrete counts | Number of successes; events per hour |
5.2 Ways to analyze a distribution
A) Visually
x = df["total_bill"]
sns.histplot(x, kde=True) # shape
sns.boxplot(x=x) # spread + outliers
sns.ecdfplot(x) # cumulative distribution
Q–Q plot (quantile–quantile plot): compares your data's quantiles with those of a theoretical distribution (usually normal).
- Points fall on the straight line → data looks normal.
- Curved ends (S-shape) → heavy or light tails.
- Bent overall curve → skewness.
stats.probplot(x, dist="norm", plot=plt)
plt.show()
B) Numerically
- Mean, median, mode, standard deviation, quartiles
- Skewness and kurtosis (next section)
C) With statistical normality tests
| Test | Notes |
|---|---|
| Shapiro–Wilk | Good for small/medium samples (< ~5000) |
| D'Agostino's K² | Based on skewness and kurtosis |
| Kolmogorov–Smirnov / Anderson–Darling | Compare against a reference distribution |
stat, p = stats.shapiro(x)
print(f"p-value = {p:.4f}")
# H0: data is normally distributed
# p < 0.05 -> reject H0 -> evidence the data is NOT normal
Caution: with very large samples, these tests flag even trivial departures from normality as "significant". Rely on the plots together with the tests.
5.3 Mean vs Median as a quick distribution clue
| Relationship | Suggests |
|---|---|
| Mean ≈ Median | Roughly symmetric |
| Mean > Median | Right-skewed |
| Mean < Median | Left-skewed |
5.4 Why it matters
- Many classic methods (linear regression residuals, t-tests, etc.) assume approximately normal data or errors.
- Skewed features can be tamed with transformations (log, square root, Box-Cox, Yeo-Johnson).
- Multimodal features hint that there are hidden sub-groups worth investigating.
6. Skewness and Kurtosis
These two numbers describe the shape of a distribution beyond its centre and spread.
6.1 Skewness: asymmetry
Skewness measures how lopsided a distribution is.
| Skewness | Name | Description | Mean vs Median |
|---|---|---|---|
| ≈ 0 | Symmetric | Both tails are balanced | Mean ≈ Median |
| > 0 | Positive / right-skewed | Long tail to the right | Mean > Median |
| < 0 | Negative / left-skewed | Long tail to the left | Mean < Median |
Negative skew Symmetric Positive skew
___ ___ ___
/ \__ / \ __/ \
/ \___ / \ ___/ \
tail on LEFT balanced tail on RIGHT
Common rule of thumb for interpretation (absolute value):
| |Skewness| | Interpretation |
|---|---|
| < 0.5 | Approximately symmetric |
| 0.5 – 1 | Moderately skewed |
| > 1 | Highly skewed |
6.2 Kurtosis: tailedness (and peakedness)
Kurtosis measures how heavy the tails are compared to a normal distribution, in other words how prone the data is to producing extreme values/outliers.
It is most commonly reported as excess kurtosis (= kurtosis − 3), so a normal distribution has an excess kurtosis of 0.
| Excess kurtosis | Name | Meaning |
|---|---|---|
| ≈ 0 | Mesokurtic | Tails like a normal distribution |
| > 0 | Leptokurtic | Heavy tails, sharper peak, more outliers |
| < 0 | Platykurtic | Light tails, flatter peak, fewer outliers |
Note on conventions: Some tools report raw kurtosis (normal = 3), others report excess kurtosis (normal = 0). Pandas
.kurt()and SciPy'skurtosis()(default) both return excess kurtosis. Always check which one you're reading.
6.3 Computing them
x = df["total_bill"]
print("Skewness:", x.skew()) # pandas
print("Kurtosis:", x.kurt()) # pandas (excess)
print("Skewness:", stats.skew(x)) # scipy (biased by default)
print("Kurtosis:", stats.kurtosis(x)) # scipy (excess, Fisher)
# For all numerical columns at once
num_df.skew()
num_df.kurt()
6.4 What to do with them
| Finding | Possible action |
|---|---|
| High positive skew | Try log, sqrt, Box-Cox or Yeo-Johnson transforms |
| High negative skew | Reflect then log (or use a power transform) |
| High kurtosis | Investigate outliers; consider capping/winsorizing or robust methods |
| Low kurtosis | Usually harmless; data is flat/light-tailed |
# Example: fixing right-skew
df["total_bill_log"] = np.log1p(df["total_bill"])
print(df["total_bill"].skew(), "->", df["total_bill_log"].skew())
7. Class Distribution Analysis
When your target is categorical (classification problems), you must check how many examples each class has.
7.1 Why it matters
If one class dominates, a model can look great while being useless. Example: if 95% of transactions are legitimate, a model that always predicts "legitimate" scores 95% accuracy but catches zero fraud. (This is called the accuracy paradox.)
7.2 How to check
target = "smoker"
# Counts and proportions
df[target].value_counts()
df[target].value_counts(normalize=True) * 100
# Visual
sns.countplot(data=df, x=target)
# Pie chart (only if there are few classes)
df[target].value_counts().plot.pie(autopct="%1.1f%%")
7.3 Measuring imbalance
Imbalance ratio = (size of the majority class) / (size of the minority class)
counts = df[target].value_counts()
imbalance_ratio = counts.max() / counts.min()
print(f"Imbalance ratio: {imbalance_ratio:.1f} : 1")
Rough guide:
| Minority share of data | Level |
|---|---|
| 40–50% | Balanced |
| 20–40% | Mild imbalance |
| 1–20% | Moderate imbalance |
| < 1% | Extreme imbalance |
7.4 Going a step further
- Binary vs multi-class: check every class, not just the biggest and smallest. Tiny classes can hide in multi-class problems.
- Class vs features: see how features behave per class, e.g. box/violin plots with
x=target, or pair plots withhue=target. If a feature looks very different across classes, it's probably useful for prediction. - Class vs categorical features: use a crosstab or a stacked bar chart.
sns.violinplot(data=df, x=target, y="total_bill")
pd.crosstab(df["day"], df[target], normalize="index").plot(kind="bar", stacked=True)
Also remember: imbalance means you should pick evaluation metrics beyond accuracy (precision, recall, F1, ROC-AUC, PR-AUC) and split data in a way that preserves class proportions (stratified splitting).
8. Detecting Relationships Between Features
This section pulls together how to choose the right tool depending on the types of the two features you're comparing.
8.1 Numerical ↔ Numerical
| Tool | What it tells you |
|---|---|
| Scatter plot | Shape, direction, strength, outliers |
| Pearson correlation | Strength of linear relationship |
| Spearman / Kendall | Strength of monotonic relationship |
| Mutual information | Any relationship, including non-linear |
from sklearn.feature_selection import mutual_info_regression
X = df[["total_bill", "size"]]
y = df["tip"]
mi = mutual_info_regression(X, y, random_state=0)
pd.Series(mi, index=X.columns).sort_values(ascending=False)
Mutual information is 0 when features are independent, and larger when knowing one tells you more about the other. It has no upper limit of 1 like correlation does.
8.2 Numerical ↔ Categorical
| Tool | What it tells you |
|---|---|
| Box / violin plot per category | Do the groups differ in centre, spread, shape? |
Group statistics (groupby().mean()) | Numeric comparison between groups |
| t-test (2 groups) | Are two means significantly different? |
| ANOVA (3+ groups) | Are any group means significantly different? |
| Kruskal–Wallis / Mann–Whitney | Non-parametric alternatives when data isn't normal |
| Point-biserial correlation | Numeric vs a binary categorical |
df.groupby("day")["total_bill"].agg(["mean", "median", "std"])
groups = [g["total_bill"].values for _, g in df.groupby("day")]
f_stat, p = stats.f_oneway(*groups) # ANOVA
print(f"ANOVA p-value: {p:.4f}")
8.3 Categorical ↔ Categorical
| Tool | What it tells you |
|---|---|
| Crosstab (contingency table) | Counts/proportions for each combination |
| Stacked / grouped bar chart | Visual comparison |
| Chi-square test of independence | Are the two features statistically related? |
| Cramér's V | Strength of association (0 to 1) |
ct = pd.crosstab(df["day"], df["smoker"])
chi2, p, dof, expected = stats.chi2_contingency(ct)
print(f"Chi-square p-value: {p:.4f}")
# Cramér's V
n = ct.values.sum()
cramers_v = np.sqrt(chi2 / (n * (min(ct.shape) - 1)))
print("Cramér's V:", round(cramers_v, 3))
8.4 Quick decision table
| Types | Visual | Number / test |
|---|---|---|
| Num – Num | Scatter plot | Pearson / Spearman / Mutual info |
| Num – Cat | Box / violin plot | t-test, ANOVA, Kruskal–Wallis |
| Cat – Cat | Stacked bar, heatmap of crosstab | Chi-square, Cramér's V |
8.5 Types of relationships to recognize
- Linear: a straight-line pattern
- Non-linear: curved, U-shaped, exponential, etc.
- Monotonic: always rising (or always falling), not necessarily straight
- Interaction effects: the relationship between A and B changes depending on C (spot them with
hueor facets) - Spurious / confounded relationships: the link is caused by a hidden third variable
9. Detecting Multicollinearity
9.1 What is it?
Multicollinearity happens when two or more input features are highly correlated with each other, so they carry overlapping information.
- Perfect multicollinearity: one feature is an exact linear combination of others (e.g.
total = a + band you include all three). - High multicollinearity: strong, but not exact, correlation.
9.2 Why is it a problem?
Especially for linear and logistic regression (and any model that interprets coefficients):
- Coefficient estimates become unstable: tiny changes in the data can swing them wildly.
- Standard errors inflate, so truly important features can look statistically insignificant.
- Coefficients may have the wrong sign or be hard to interpret.
- It's difficult to separate the individual effect of each feature.
Good to know: multicollinearity mainly hurts interpretation and stability. A model's overall predictive power may be unaffected, and some tree-based models are far less sensitive to it.
9.3 How to detect it
Method 1: Correlation matrix / heatmap
Look for pairs of input features with very high absolute correlation (a common warning threshold is |r| > 0.8, sometimes 0.7).
corr = num_df.corr().abs()
upper = corr.where(np.triu(np.ones(corr.shape), k=1).astype(bool))
high_corr = [(c, r, upper.loc[r, c]) for c in upper.columns for r in upper.index
if pd.notna(upper.loc[r, c]) and upper.loc[r, c] > 0.8]
high_corr
Limitation: it only detects pairwise relationships. A feature can be predicted from a combination of several others without any single pair looking suspicious. That's what VIF solves.
Method 2: Variance Inflation Factor (VIF)
For each feature, VIF measures how much the variance of its coefficient is inflated because of correlation with the other features.
How it works: regress feature j on all other features, get that regression's R², then:
VIF_j = 1 / (1 − R²_j)
| VIF | Interpretation |
|---|---|
| 1 | No correlation with other features |
| 1 – 5 | Low to moderate, generally fine |
| 5 – 10 | High, worth investigating |
| > 10 | Severe multicollinearity |
from statsmodels.stats.outliers_influence import variance_inflation_factor
from statsmodels.tools.tools import add_constant
X = add_constant(num_df.drop(columns="tip")) # features only, no target
vif = pd.DataFrame({
"feature": X.columns,
"VIF": [variance_inflation_factor(X.values, i) for i in range(X.shape[1])]
})
vif = vif[vif["feature"] != "const"] # ignore the intercept row
print(vif.sort_values("VIF", ascending=False))
Related measure: Tolerance = 1 / VIF = 1 − R². Small tolerance (e.g. < 0.1) signals a problem.
Method 3: Other signs
- Condition number of the feature matrix: large values (commonly > 30) suggest collinearity.
- Unstable coefficients: big changes when you add/remove a feature or a few rows.
- High R² but insignificant individual coefficients.
- Unexpected coefficient signs.
9.4 What to do when you find it
| Option | Idea |
|---|---|
| Drop one of the correlated features | Keep the one that's easier to interpret or more related to the target |
| Combine features | Average them, or create a ratio/index |
| Use dimensionality reduction | e.g. PCA creates uncorrelated components |
| Use regularization | Ridge / Lasso handle correlated features better |
| Collect more data | Sometimes reduces instability |
Remember: check multicollinearity among the input features only. High correlation between a feature and the target is a good thing.
10. Data Profiling and Summary Statistics
Data profiling is the systematic first look at a dataset: its structure, quality, and basic statistics. It's usually the very first step of EDA, giving you a map before you dive into plots.
10.1 Structure checks
df.shape # (rows, columns)
df.head() # first rows
df.tail() # last rows
df.sample(5) # random rows
df.columns # column names
df.dtypes # data type of each column
df.info() # dtypes + non-null counts + memory usage
Ask yourself:
- Are the data types right? (e.g. dates stored as strings, numbers stored as text, categories stored as integers)
- Is each column what its name suggests?
10.2 Summary statistics
df.describe() # numerical columns
df.describe(include="object") # categorical (use "category" too if applicable)
df.describe(include="all") # everything
Statistics you'll see and what they tell you:
| Statistic | Meaning | Why it's useful |
|---|---|---|
| count | Non-missing values | Reveals missing data |
| mean | Average | Central tendency (sensitive to outliers) |
| std | Standard deviation | Typical spread around the mean |
| min / max | Extremes | Range, impossible values (e.g. negative age) |
| 25% / 50% / 75% | Quartiles (50% = median) | Robust centre and spread |
| unique | Distinct categories | Cardinality of a categorical feature |
| top / freq | Most common value and its count | Dominant category |
More measures worth adding:
| Measure | Code | Use |
|---|---|---|
| Median | df.median(numeric_only=True) | Robust centre |
| Mode | df.mode() | Most frequent value |
| Variance | df.var(numeric_only=True) | Spread (squared units) |
| Range | df.max() - df.min() | Total spread |
| IQR | Q3 - Q1 | Robust spread |
| Coefficient of variation | std / mean | Spread relative to the mean, comparable across features |
| Skewness / Kurtosis | df.skew(), df.kurt() | Shape |
| Unique counts | df.nunique() | Cardinality, constant columns |
10.3 Data quality profiling
# Missing values
df.isnull().sum()
(df.isnull().mean() * 100).round(2) # % missing per column
# Duplicates
df.duplicated().sum()
# Constant / near-constant columns (carry little information)
df.nunique()[df.nunique() <= 1]
# High-cardinality categoricals
df.select_dtypes("object").nunique().sort_values(ascending=False)
Visualizing missingness:
sns.heatmap(df.isnull(), cbar=False)
Quality checklist:
- Missing values: how many, which columns, any pattern?
- Duplicate rows
- Wrong data types
- Impossible / out-of-range values
- Inconsistent labels (
"Male","male","M") - Outliers (box plots, IQR rule, z-scores)
- Constant or near-constant columns
- Very high cardinality categorical columns
10.4 Outlier detection using summary stats
IQR method: flag values outside [Q1 − 1.5·IQR, Q3 + 1.5·IQR] (shown earlier with box plots).
Z-score method (best for roughly normal data): flag values with |z| > 3.
z = np.abs(stats.zscore(df["total_bill"]))
df[z > 3]
10.5 Automated profiling reports
Libraries can build a full EDA report (stats, distributions, correlations, missing values, alerts) in a single command:
# pip install ydata-profiling
from ydata_profiling import ProfileReport
profile = ProfileReport(df, title="EDA Report", explorative=True)
profile.to_file("eda_report.html")
Automated reports are great for a fast overview, but they don't replace thinking. Use them to find where to look, then dig in manually.
Quick Cheat Sheet
| I want to... | Use |
|---|---|
| See the shape of one numeric feature | sns.histplot(kde=True) |
| Spot outliers & compare groups | sns.boxplot |
| See shape and summary per group | sns.violinplot |
| Relationship between two numbers | sns.scatterplot / regplot |
| All pairwise relationships at once | sns.pairplot |
| Correlation of all numeric features | df.corr() + sns.heatmap |
| Is it normal? | Histogram, Q–Q plot, Shapiro–Wilk |
| How lopsided is it? | .skew() |
| How heavy are the tails? | .kurt() |
| Are my classes balanced? | value_counts(normalize=True), countplot |
| Num vs Cat relationship | Box/violin, t-test / ANOVA |
| Cat vs Cat relationship | Crosstab, chi-square, Cramér's V |
| Non-linear dependence | Spearman, mutual information |
| Redundant input features | Correlation heatmap, VIF |
| First look at the data | .info(), .describe(), .isnull().sum() |
A sensible EDA workflow
1. Profile the data -> shape, dtypes, missing values, duplicates, describe()
2. Univariate analysis -> histograms, box plots, value counts, skewness/kurtosis
3. Class distribution -> check target balance (if classification)
4. Bivariate analysis -> scatter plots, grouped box/violin plots, crosstabs
5. Multivariate analysis -> pair plot, correlation heatmap, hue/facets
6. Relationship tests -> correlation, ANOVA, chi-square, mutual information
7. Multicollinearity -> correlation among inputs, VIF
8. Write down findings -> what to clean, transform, drop, or engineer
Key Takeaways
- EDA is about asking questions and letting both numbers and pictures answer them.
- Never trust a single number. Always back correlations and summary statistics with a plot.
- Distribution shape matters: skewness and kurtosis tell you if transformations or outlier handling are needed.
- Check class balance early: it determines how you should evaluate a classifier.
- Pick tools by data type (num/cat) when studying relationships.
- Multicollinearity is about inputs being redundant with each other; use correlation heatmaps for pairs and VIF for combinations.
- Start with profiling, because dirty or mistyped data will mislead every later step.
#Supervised Learning: Regression#
Regression = predicting a number. House price, temperature, salary, sales next month: anything where the answer is a continuous value. You show the model many examples with known answers (that's the "supervised" part), and it learns a rule that maps inputs to the output.
These notes go from the simplest straight-line model to modern gradient boosting libraries, and finish with the checks you should run to know whether your regression can be trusted.
Table of Contents
- Foundations: the regression setup
- Simple Linear Regression
- Multiple Linear Regression
- Polynomial Regression
- Nonlinear Regression
- Ridge Regression (L2)
- Lasso Regression (L1)
- Elastic Net Regression
- Bayesian Regression
- Support Vector Regression (SVR)
- Decision Tree Regression
- Random Forest Regression
- Extra Trees Regression
- Gradient Boosting Regression
- AdaBoost Regression
- XGBoost Regression
- LightGBM Regression
- CatBoost Regression
- Quantile Regression
- Robust Regression
- Regression Assumptions and Diagnostics
- Model Comparison Cheat Sheet
0. Foundations: the regression setup
The big picture
Inputs (features) X ──► Model f(X) ──► Predicted number ŷ
│
compare with true y
│
error / loss to minimize
- Features (X): the input columns.
- Target (y): the continuous number you want to predict.
- Residual:
y − ŷ(actual minus predicted). - Loss function: a number that says how bad the predictions are; training = making it small.
Evaluation metrics (used throughout these notes)
| Metric | Formula (idea) | Notes |
|---|---|---|
| MAE | mean of ` | y − ŷ |
| MSE | mean of (y − ŷ)² | Punishes big errors heavily |
| RMSE | √MSE | Same units as y; most common |
| R² | 1 − SS_res / SS_tot | Share of variance explained; 1 is perfect, 0 equals predicting the mean, can be negative on test data |
| Adjusted R² | R² penalized for number of features | Prevents "more features always looks better" |
Bias–variance tradeoff (the idea behind almost every model below)
- High bias (underfitting): model too simple, misses real patterns.
- High variance (overfitting): model too flexible, memorizes noise.
- Goal: the sweet spot where test error is lowest.
Common setup for the code examples
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.datasets import make_regression
from sklearn.model_selection import train_test_split, cross_val_score, GridSearchCV
from sklearn.preprocessing import StandardScaler, PolynomialFeatures
from sklearn.pipeline import make_pipeline
from sklearn.metrics import mean_squared_error, mean_absolute_error, r2_score
# Synthetic data so every example runs without downloading anything
X, y = make_regression(n_samples=500, n_features=8, n_informative=5,
noise=15, random_state=42)
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, random_state=42
)
def evaluate(model, X_te=X_test, y_te=y_test):
pred = model.predict(X_te)
return {
"RMSE": np.sqrt(mean_squared_error(y_te, pred)),
"MAE": mean_absolute_error(y_te, pred),
"R2": r2_score(y_te, pred),
}
Golden rules: always evaluate on unseen data (test set or cross-validation), and put scaling inside a
Pipelineso test data never leaks into training.
1. Simple Linear Regression
Idea
Fit the best straight line through the data using one input feature.
y = β₀ + β₁·x + ε
β₀(intercept): predictedywhenx = 0β₁(slope): change inyfor a one-unit increase inxε(error): the part the line can't explain
How the line is found: Ordinary Least Squares (OLS)
OLS picks β₀ and β₁ that minimize the sum of squared residuals:
minimize Σ (yᵢ − (β₀ + β₁·xᵢ))²
There is a closed-form answer:
β₁ = Cov(x, y) / Var(x)
β₀ = ȳ − β₁ · x̄
Example
from sklearn.linear_model import LinearRegression
x_single = X_train[:, [0]] # keep it 2-D: (n_samples, 1)
slr = LinearRegression().fit(x_single, y_train)
print("Intercept:", slr.intercept_)
print("Slope :", slr.coef_[0])
print(evaluate(slr, X_test[:, [0]], y_test))
Interpreting the output
"For each one-unit increase in
x, the model predictsychanges byβ₁units on average."
Strengths and limits
| Strengths | Limits |
|---|---|
| Very easy to explain and visualize | Only one feature |
| Fast; no tuning | Assumes a straight-line relationship |
| Closed-form solution | Sensitive to outliers |
Use it when: you want a quick baseline or a clean, explainable relationship between two variables.
2. Multiple Linear Regression
Idea
Same as simple linear regression, but with many features:
y = β₀ + β₁x₁ + β₂x₂ + … + βₚxₚ + ε
In matrix form, the OLS solution is the Normal Equation:
β = (XᵀX)⁻¹ Xᵀ y
(In practice, libraries use numerically stabler methods such as SVD, but the result is the same. Gradient descent is an alternative for very large datasets.)
Interpreting coefficients
Each βⱼ means: "the expected change in y for a one-unit increase in xⱼ, holding all other features constant."
If features are on different scales, raw coefficients aren't directly comparable. Standardize first if you want to compare their importance.
Example
mlr = LinearRegression().fit(X_train, y_train)
coef = pd.Series(mlr.coef_, index=[f"x{i}" for i in range(X.shape[1])])
print(coef.sort_values(key=abs, ascending=False))
print(evaluate(mlr))
Practical points
- Categorical features must be encoded (one-hot; drop one level to avoid redundancy).
- Multicollinearity (features highly correlated with each other) makes coefficients unstable. Check VIF (see section 20).
- More features always raises R² on training data, so use adjusted R² or cross-validation.
- Check the assumptions in section 20 if you plan to interpret coefficients or build confidence intervals.
Strengths and limits
| Strengths | Limits |
|---|---|
| Interpretable coefficients | Assumes linearity and additive effects |
| Fast and stable on clean data | Breaks down with strong multicollinearity |
| Great baseline | Prone to overfitting with many features and few rows |
3. Polynomial Regression
Idea
When the relationship curves, add powers of the features and fit a line to those:
y = β₀ + β₁x + β₂x² + β₃x³ + … + β_d·x^d
Key insight: it's still a linear model, because it is linear in the coefficients. You are just giving it new, curved features (
x²,x³, ...). That's why plain OLS can fit it.
With several features, PolynomialFeatures also creates interaction terms (e.g. x₁·x₂).
Example
from sklearn.linear_model import LinearRegression
# Curvy 1-D data
rng = np.random.default_rng(0)
x = np.sort(rng.uniform(-3, 3, 200)).reshape(-1, 1)
y_curve = 0.5 * x.ravel()**3 - x.ravel()**2 + 2 + rng.normal(0, 2, 200)
poly_model = make_pipeline(
PolynomialFeatures(degree=3, include_bias=False),
StandardScaler(),
LinearRegression()
)
poly_model.fit(x, y_curve)
Choosing the degree
| Degree | Behaviour |
|---|---|
| Too low | Underfits: can't capture the curve |
| Just right | Captures the pattern |
| Too high | Overfits: wiggles wildly, especially near the edges of the data |
Pick the degree using cross-validation:
for d in range(1, 9):
pipe = make_pipeline(PolynomialFeatures(d, include_bias=False),
StandardScaler(), LinearRegression())
score = cross_val_score(pipe, x, y_curve, cv=5,
scoring="neg_root_mean_squared_error").mean()
print(d, round(-score, 3))
Watch out for
- Features explode in size (
x¹⁰is huge), so scale them. - The number of features grows fast with several inputs and high degree.
- Polynomials extrapolate badly outside the training range.
- Combining with Ridge/Lasso (sections 5–6) helps tame high-degree models.
4. Nonlinear Regression
Idea
In nonlinear regression, the parameters appear inside the function in a nonlinear way, so you can't turn it into a plain linear problem just by adding features.
| Model | Form | Typical use |
|---|---|---|
| Exponential growth/decay | y = a·e^(b·x) | Population, radioactive decay |
| Power law | y = a·x^b | Scaling relationships |
| Logistic (S-curve) growth | y = L / (1 + e^(−k(x − x₀))) | Adoption, saturation |
| Michaelis–Menten | y = (Vmax·x) / (K + x) | Enzyme kinetics |
Polynomial vs nonlinear: the difference
- Polynomial regression: linear in parameters → solved directly.
- Nonlinear regression: nonlinear in parameters → solved iteratively.
How it is fitted
It still minimizes squared error, but with an iterative optimizer (e.g. Gauss–Newton or Levenberg–Marquardt). You must supply:
- A functional form (chosen from domain knowledge or from looking at the data).
- Initial guesses for the parameters. Bad guesses can cause failure or a poor local solution.
Example
from scipy.optimize import curve_fit
def logistic(x, L, k, x0):
return L / (1 + np.exp(-k * (x - x0)))
rng = np.random.default_rng(1)
xs = np.linspace(0, 10, 100)
ys = logistic(xs, 10, 1.2, 5) + rng.normal(0, 0.4, xs.size)
params, cov = curve_fit(logistic, xs, ys, p0=[8, 1, 4]) # p0 = initial guess
L, k, x0 = params
param_se = np.sqrt(np.diag(cov)) # standard errors
print(params, param_se)
A trick: linearize with a transformation
Some models can be turned linear by transforming variables:
y = a·e^(b·x) → ln(y) = ln(a) + b·x (fit a straight line to ln(y))
y = a·x^b → ln(y) = ln(a) + b·ln(x)
This is simple, but it changes how errors are weighted, so true nonlinear fitting is often more accurate.
Strengths and limits
| Strengths | Limits |
|---|---|
| Parameters have real-world meaning | Need to know / guess the functional form |
| Extrapolates sensibly if the form is right | Needs good starting values |
| Few parameters, compact model | May not converge; can hit local minima |
5. Ridge Regression (L2 Regularization)
Why regularize?
With many features, or correlated ones, ordinary least squares can produce huge, unstable coefficients and overfit. Regularization adds a penalty that discourages large coefficients.
Idea
Ridge adds an L2 penalty (sum of squared coefficients) to the loss:
Loss = Σ (yᵢ − ŷᵢ)² + α · Σ βⱼ²
α(alpha) controls the strength of the penalty.α = 0→ ordinary least squaresαlarge → coefficients shrink toward zero
- The intercept is not penalized.
Closed form: β = (XᵀX + αI)⁻¹ Xᵀ y. Adding αI makes the matrix always invertible, which is why Ridge handles multicollinearity well.
Key behaviour
- Shrinks coefficients smoothly, but never exactly to zero (keeps all features).
- Correlated features end up sharing the weight.
- Trades a little bias for a big drop in variance.
Example
from sklearn.linear_model import Ridge, RidgeCV
ridge = make_pipeline(
StandardScaler(), # scaling is essential
RidgeCV(alphas=np.logspace(-3, 3, 50), cv=5) # picks the best alpha
)
ridge.fit(X_train, y_train)
print("Best alpha:", ridge[-1].alpha_)
print(evaluate(ridge))
Practical notes
- Always standardize features first, because the penalty depends on coefficient size, which depends on feature scale.
- Tune
αwith cross-validation. - Ridge improves predictions but does not do feature selection.
6. Lasso Regression (L1 Regularization)
Idea
Lasso (Least Absolute Shrinkage and Selection Operator) uses an L1 penalty, the sum of absolute coefficient values:
Loss = Σ (yᵢ − ŷᵢ)² + α · Σ |βⱼ|
Key behaviour
- Can push some coefficients to exactly zero, so Lasso automatically selects features.
- Produces sparse, easier-to-interpret models.
- No closed-form solution; solved with coordinate descent.
Why L1 gives zeros but L2 doesn't (intuition)
The L1 constraint region has sharp corners on the axes. The loss contours tend to touch the region at a corner, where some coefficients are exactly 0. The L2 region is a smooth circle with no corners, so coefficients get small but not zero.
Example
from sklearn.linear_model import Lasso, LassoCV
lasso = make_pipeline(StandardScaler(), LassoCV(cv=5, random_state=42))
lasso.fit(X_train, y_train)
coefs = pd.Series(lasso[-1].coef_, index=[f"x{i}" for i in range(X.shape[1])])
print("Kept features:", list(coefs[coefs != 0].index))
print("Zeroed out :", list(coefs[coefs == 0].index))
print(evaluate(lasso))
Limits
- With a group of highly correlated features, Lasso tends to pick one arbitrarily and drop the rest, and the choice can be unstable.
- With more features than samples (
p > n), it selects at mostnfeatures.
7. Elastic Net Regression
Idea
Elastic Net combines L1 and L2 penalties, getting the sparsity of Lasso and the stability of Ridge:
Loss = Σ (yᵢ − ŷᵢ)² + α · [ ρ · Σ|βⱼ| + (1 − ρ)/2 · Σβⱼ² ]
α: overall penalty strengthρ(l1_ratioin scikit-learn): the mixρ = 1→ pure Lassoρ = 0→ pure Ridge- in between → a blend
Why use it?
- Handles groups of correlated features better than Lasso (the grouping effect: correlated features are kept or dropped together).
- Still performs feature selection.
- Useful when features outnumber samples.
Example
from sklearn.linear_model import ElasticNetCV
enet = make_pipeline(
StandardScaler(),
ElasticNetCV(l1_ratio=[.1, .3, .5, .7, .9, .95, 1.0], cv=5, random_state=42)
)
enet.fit(X_train, y_train)
print("alpha :", enet[-1].alpha_)
print("l1_ratio:", enet[-1].l1_ratio_)
print(evaluate(enet))
Ridge vs Lasso vs Elastic Net
| Ridge | Lasso | Elastic Net | |
|---|---|---|---|
| Penalty | L2 | L1 | L1 + L2 |
| Coefficients to exactly 0? | No | Yes | Yes |
| Handles correlated features | Well (shares weight) | Poorly (picks one) | Well (groups) |
| Feature selection | No | Yes | Yes |
| Hyperparameters | α | α | α, l1_ratio |
8. Bayesian Regression
Idea
Instead of finding one best set of coefficients, Bayesian regression finds a probability distribution over coefficients. You start with a prior belief, update it with data, and get a posterior.
Posterior ∝ Likelihood × Prior
p(β | data) ∝ p(data | β) · p(β)
What you gain
- Uncertainty estimates: predictions come with a standard deviation, not just a single number.
- Built-in regularization through the prior.
- Works well with small datasets.
Connection to what you've already learned
| Prior on coefficients | Equivalent to (MAP estimate) |
|---|---|
| Gaussian (normal) | Ridge (L2) |
| Laplace | Lasso (L1) |
So Ridge and Lasso can be seen as Bayesian regression where you only keep the most probable coefficients.
In practice
BayesianRidge(scikit-learn): Gaussian priors; it learns the regularization strength from the data automatically.ARDRegression: Automatic Relevance Determination, giving each feature its own prior precision, which encourages sparsity.- Full Bayesian modelling (via MCMC or variational inference in probabilistic programming libraries) lets you choose any prior and get the full posterior.
Example
from sklearn.linear_model import BayesianRidge
bayes = BayesianRidge().fit(X_train, y_train)
mean_pred, std_pred = bayes.predict(X_test, return_std=True)
print("First prediction:", mean_pred[0], "±", std_pred[0])
# Approximate 95% interval
lower = mean_pred - 1.96 * std_pred
upper = mean_pred + 1.96 * std_pred
print(evaluate(bayes))
Strengths and limits
| Strengths | Limits |
|---|---|
| Gives uncertainty, not just point estimates | Prior choice influences results |
| Auto-tunes regularization (BayesianRidge) | Full Bayesian inference can be slow |
| Good for small data | Linear form is still assumed (in these versions) |
9. Support Vector Regression (SVR)
Idea
SVR tries to fit a function so that most points lie within a tube of width ε (epsilon) around it. Errors inside the tube are ignored; only points outside the tube are penalized.
· · ← points outside the tube are penalized
───────────────── upper edge (+ε)
═════════════════ fitted function
───────────────── lower edge (−ε)
· · ← points inside are free (zero loss)
This is the ε-insensitive loss:
loss = 0 if |y − ŷ| ≤ ε
loss = |y − ŷ| − ε otherwise
Key terms
| Term | Meaning |
|---|---|
| Support vectors | Training points on or outside the tube; only these define the model |
| ε (epsilon) | Tube width; larger → simpler model with fewer support vectors |
| C | Penalty for points outside the tube; larger → fits training data harder (risk of overfitting) |
| Kernel | Lets SVR fit curves: linear, poly, rbf (most popular), sigmoid |
| gamma | RBF/poly kernel reach; larger → wigglier, more local fit |
Example
from sklearn.svm import SVR
svr = make_pipeline(
StandardScaler(), # SVR needs scaled features
SVR(kernel="rbf", C=100, epsilon=0.1, gamma="scale")
)
svr.fit(X_train, y_train)
print(evaluate(svr))
Also scale the target when it has a very large range, or
epsilonandCbecome hard to tune (TransformedTargetRegressorhelps).
Tuning
param_grid = {"svr__C": [1, 10, 100], "svr__epsilon": [0.01, 0.1, 1], "svr__gamma": ["scale", 0.01, 0.1]}
GridSearchCV(svr, param_grid, cv=5, scoring="neg_root_mean_squared_error").fit(X_train, y_train)
Strengths and limits
| Strengths | Limits |
|---|---|
| Handles nonlinear patterns via kernels | Slow on large datasets (training scales badly with rows) |
| Robust to small noise (the ε-tube) | Requires feature scaling |
| Works well in high dimensions | Several hyperparameters to tune |
| Not very interpretable |
For large data with a linear relationship, use LinearSVR.
10. Decision Tree Regression
Idea
A decision tree predicts by asking a series of yes/no questions about the features, splitting the data into regions. Each final region (leaf) predicts the average y of the training points in it.
Is size > 1500 sq ft?
/ \
Yes No
Is age < 10? Is rooms > 2?
/ \ / \
₹80L ₹60L ₹45L ₹30L ← leaf = mean of samples
How splits are chosen
At each node, the tree tries every feature and threshold and picks the split that gives the biggest reduction in error (variance / MSE) in the child nodes:
Gain = MSE(parent) − weighted average MSE(children)
It repeats recursively (a greedy process) until a stopping rule is hit.
Key hyperparameters (controlling complexity)
| Parameter | Effect |
|---|---|
max_depth | Limits tree depth (the main overfitting control) |
min_samples_split | Minimum samples needed to split a node |
min_samples_leaf | Minimum samples allowed in a leaf |
max_features | Number of features considered per split |
ccp_alpha | Cost-complexity pruning strength (post-pruning) |
criterion | squared_error, absolute_error, friedman_mse, poisson |
Example
from sklearn.tree import DecisionTreeRegressor, plot_tree
tree = DecisionTreeRegressor(max_depth=5, min_samples_leaf=10, random_state=42)
tree.fit(X_train, y_train)
print(evaluate(tree))
plt.figure(figsize=(14, 6))
plot_tree(tree, max_depth=2, filled=True, feature_names=[f"x{i}" for i in range(8)])
plt.show()
Characteristics
- Predictions are piecewise constant (a staircase), not smooth.
- No feature scaling needed; handles nonlinear relationships and interactions automatically.
- Cannot extrapolate: outside the training range it just repeats the edge leaf's value.
- High variance: small data changes can produce a very different tree. An unpruned tree memorizes the training data.
This weakness is exactly why ensembles (next sections) exist.
11. Random Forest Regression
Idea
Train many decision trees and average their predictions. Two sources of randomness make the trees different from each other:
- Bagging (bootstrap aggregating): each tree trains on a random sample of rows drawn with replacement.
- Random feature subsets: each split considers only a random subset of features.
Data ──► Bootstrap 1 ──► Tree 1 ─┐
──► Bootstrap 2 ──► Tree 2 ─┼──► Average ──► Final prediction
──► Bootstrap n ──► Tree n ─┘
Averaging many different, overfit-prone trees cancels out their individual errors, which drastically lowers variance.
Key hyperparameters
| Parameter | Effect |
|---|---|
n_estimators | Number of trees (more is generally better, with diminishing returns) |
max_features | Features tried per split (lower → more diverse trees) |
max_depth, min_samples_leaf | Control individual tree complexity |
bootstrap | Whether to sample with replacement |
oob_score | Use left-out rows to estimate performance for free |
Out-of-Bag (OOB) score
Each bootstrap sample leaves out roughly 37% of rows. Those unseen rows act as a built-in validation set.
Example
from sklearn.ensemble import RandomForestRegressor
rf = RandomForestRegressor(
n_estimators=300, max_features=0.5, min_samples_leaf=3,
oob_score=True, n_jobs=-1, random_state=42
)
rf.fit(X_train, y_train)
print("OOB R²:", rf.oob_score_)
print(evaluate(rf))
importances = pd.Series(rf.feature_importances_,
index=[f"x{i}" for i in range(8)]).sort_values(ascending=False)
print(importances)
Characteristics
- Strong out-of-the-box performance with little tuning.
- Handles nonlinearity, interactions, and mixed feature types; no scaling needed.
- Provides feature importance.
- Still cannot extrapolate beyond the training target range.
- Larger and slower to predict than a single tree; less interpretable.
Note on importance: built-in impurity-based importance can favour high-cardinality or continuous features. Permutation importance is a more reliable alternative.
12. Extra Trees Regression
Idea
Extremely Randomized Trees work like a random forest, with one extra twist: at each split, instead of searching for the best threshold for each candidate feature, Extra Trees picks the threshold at random and then chooses the best among those random splits.
Differences from Random Forest
| Random Forest | Extra Trees | |
|---|---|---|
| Row sampling | Bootstrap (default) | Uses the whole dataset (default, bootstrap=False) |
| Split thresholds | Optimized | Random |
| Variance | Low | Even lower |
| Bias | Lower | Slightly higher |
| Training speed | Slower | Faster (no threshold search) |
Why it works
The added randomness makes trees more decorrelated and smooths the model, which reduces variance further. It can act like a regularizer, especially on noisy data.
Example
from sklearn.ensemble import ExtraTreesRegressor
et = ExtraTreesRegressor(n_estimators=300, min_samples_leaf=2, n_jobs=-1, random_state=42)
et.fit(X_train, y_train)
print(evaluate(et))
When to try it
- You want a fast forest-like model.
- Data is noisy and you suspect variance is the problem.
- Compare it against Random Forest by cross-validation; neither always wins.
Like other tree ensembles, it cannot extrapolate beyond the range seen in training.
13. Gradient Boosting Regression
Idea
Unlike Random Forest (trees built independently, in parallel), boosting builds trees one after another, each new tree trying to fix the mistakes of the ones before it.
Prediction = F₀ + η·T₁(x) + η·T₂(x) + η·T₃(x) + … + η·T_M(x)
F₀: starting guess (e.g. the mean ofy)Tₘ: small tree trained on the residuals (errors) of the current modelη: learning rate, a scaling factor that makes each tree contribute only a little
The algorithm in plain words
1. Start with a simple prediction (the mean of y).
2. Compute residuals = y − current prediction.
3. Fit a small tree to predict those residuals.
4. Add that tree (scaled by the learning rate) to the model.
5. Repeat steps 2–4 for M rounds.
Why "gradient"?
For squared-error loss, residuals equal the negative gradient of the loss. For other losses (absolute error, Huber, quantile) the new tree fits the negative gradient. This turns boosting into gradient descent in function space, and it means you can plug in many different loss functions.
Key hyperparameters
| Parameter | Effect |
|---|---|
n_estimators | Number of boosting rounds |
learning_rate | Smaller = more careful learning (needs more trees); the two trade off |
max_depth | Depth of each tree; typically shallow (3–5) |
subsample | Fraction of rows per tree (<1 = stochastic gradient boosting; reduces overfitting) |
loss | squared_error, absolute_error, huber, quantile |
n_iter_no_change | Early stopping patience |
Example
from sklearn.ensemble import GradientBoostingRegressor
gbr = GradientBoostingRegressor(
n_estimators=500, learning_rate=0.05, max_depth=3,
subsample=0.8, n_iter_no_change=20, validation_fraction=0.1,
random_state=42
)
gbr.fit(X_train, y_train)
print("Trees actually used:", gbr.n_estimators_)
print(evaluate(gbr))
Characteristics
- Often among the most accurate methods on tabular data.
- Boosting mainly reduces bias; unlike a forest, too many rounds will overfit, so use early stopping.
- Sequential training is slower and harder to parallelize.
- For larger datasets, scikit-learn's
HistGradientBoostingRegressoris much faster (it bins features into histograms).
14. AdaBoost Regression
Idea
AdaBoost (Adaptive Boosting) is another boosting method, but instead of fitting residuals, it re-weights the training samples: samples that the current model predicts poorly get higher weight, forcing the next learner to focus on them.
The regression version is known as AdaBoost.R2.
How it works (simplified)
1. Give every sample equal weight.
2. Train a weak learner (usually a shallow tree) on the weighted data.
3. Measure each sample's error; compute the learner's overall error.
4. Increase the weight of samples with large errors.
5. Give the learner a "say" (weight) based on how accurate it was.
6. Repeat; the final prediction is the weighted median of all learners.
Key hyperparameters
| Parameter | Effect |
|---|---|
estimator | The weak learner (default: tree of depth 3) |
n_estimators | Number of learners |
learning_rate | Shrinks each learner's contribution |
loss | linear, square, exponential: how errors are converted into weights |
Example
from sklearn.ensemble import AdaBoostRegressor
from sklearn.tree import DecisionTreeRegressor
ada = AdaBoostRegressor(
estimator=DecisionTreeRegressor(max_depth=4),
n_estimators=200, learning_rate=0.5, loss="linear", random_state=42
)
ada.fit(X_train, y_train)
print(evaluate(ada))
Gradient Boosting vs AdaBoost
| AdaBoost | Gradient Boosting | |
|---|---|---|
| What each new model focuses on | Hard samples (via weights) | Residual errors (via gradients) |
| Loss flexibility | Limited | Many loss functions |
| Noise / outliers | Sensitive: it keeps up-weighting them | Better, especially with robust losses |
| Typical accuracy | Good | Usually better |
15. XGBoost Regression
Idea
XGBoost (eXtreme Gradient Boosting) is a highly optimized, regularized implementation of gradient-boosted trees, famous for winning many tabular-data competitions.
What makes it special
| Feature | Benefit |
|---|---|
Regularized objective (L1 reg_alpha, L2 reg_lambda, and gamma for splits) | Less overfitting than plain gradient boosting |
| Second-order (Newton) optimization | Uses both gradient and curvature for better steps |
Smart tree pruning (gamma, max_depth) | Removes splits that don't add enough gain |
| Sparsity-aware splitting | Handles missing values natively by learning a default direction |
| Parallel, cache-aware computation | Fast |
| Built-in early stopping and cross-validation | Convenient |
Objective it minimizes
Objective = Σ loss(yᵢ, ŷᵢ) + Σ Ω(tree_k)
Ω(tree) = γ·(number of leaves) + ½·λ·Σ(leaf weights)² (+ α·Σ|leaf weights|)
Key hyperparameters
| Parameter | Role |
|---|---|
n_estimators | Max number of rounds (use with early stopping) |
learning_rate | Step size (try 0.01–0.1) |
max_depth | Tree depth (3–8 is common) |
min_child_weight | Minimum sum of weights in a leaf; higher = more conservative |
subsample, colsample_bytree | Row and column sampling; reduce overfitting |
gamma | Minimum loss reduction required to make a split |
reg_lambda, reg_alpha | L2 and L1 regularization |
objective | reg:squarederror, reg:absoluteerror, reg:pseudohubererror, reg:quantileerror, and more |
Example
from xgboost import XGBRegressor
X_tr, X_val, y_tr, y_val = train_test_split(X_train, y_train, test_size=0.2, random_state=42)
xgb = XGBRegressor(
n_estimators=2000, learning_rate=0.05, max_depth=4,
subsample=0.8, colsample_bytree=0.8,
reg_lambda=1.0, reg_alpha=0.0,
early_stopping_rounds=50, random_state=42, n_jobs=-1
)
xgb.fit(X_tr, y_tr, eval_set=[(X_val, y_val)], verbose=False)
print("Best iteration:", xgb.best_iteration)
print(evaluate(xgb))
Tips
- Set a small learning rate and a large
n_estimators, and let early stopping decide when to stop. - Tune in this rough order:
max_depth/min_child_weight→subsample/colsample_bytree→gammaand regularization → lower the learning rate at the end. - No scaling needed; categorical features need encoding (or use its native categorical support).
16. LightGBM Regression
Idea
LightGBM (Light Gradient Boosting Machine) is a gradient-boosting framework built for speed and low memory use, especially on large datasets.
What makes it different
| Innovation | What it does |
|---|---|
| Histogram-based splitting | Buckets continuous features into discrete bins, making split search much faster and lighter on memory |
| Leaf-wise tree growth | Grows the leaf with the largest loss reduction (best-first), instead of level by level. It usually reaches lower loss with fewer splits |
| GOSS (Gradient-based One-Side Sampling) | Keeps samples with large gradients and subsamples those with small gradients |
| EFB (Exclusive Feature Bundling) | Bundles mutually exclusive sparse features together |
| Native categorical support | Handles categorical columns without one-hot encoding |
Level-wise (XGBoost default) Leaf-wise (LightGBM)
● ●
● ● ● ○
● ● ● ● ● ●
(expands every level) (expands the best leaf only)
The important caveat
Leaf-wise growth can build deep, lopsided trees and overfit, particularly on small datasets. Control it with:
Key hyperparameters
| Parameter | Role |
|---|---|
num_leaves | Main complexity control (keep below 2^max_depth) |
max_depth | Optional cap on depth |
min_child_samples (min_data_in_leaf) | Minimum samples per leaf; raise to reduce overfitting |
learning_rate, n_estimators | Same tradeoff as other boosters |
feature_fraction (colsample_bytree), bagging_fraction (subsample) | Column/row sampling |
reg_alpha, reg_lambda | L1/L2 regularization |
max_bin | Number of histogram bins |
Example
import lightgbm as lgb
lgbm = lgb.LGBMRegressor(
n_estimators=2000, learning_rate=0.05,
num_leaves=31, min_child_samples=20,
subsample=0.8, subsample_freq=1, colsample_bytree=0.8,
random_state=42, verbose=-1
)
lgbm.fit(
X_tr, y_tr,
eval_set=[(X_val, y_val)],
callbacks=[lgb.early_stopping(50, verbose=False)]
)
print(evaluate(lgbm))
When to choose it
- Large datasets and many features (very fast).
- Datasets with many categorical columns.
- Be more careful and tune
num_leaves/min_child_sampleson small datasets.
17. CatBoost Regression
Idea
CatBoost (Categorical Boosting) is a gradient-boosting library designed to handle categorical features natively and well, and to give strong results with minimal tuning.
What makes it different
| Innovation | What it does |
|---|---|
| Ordered target statistics | Encodes a category using the target mean of only the earlier rows (in a random order), avoiding target leakage |
| Ordered boosting | Computes each row's gradient with a model that hasn't seen that row, reducing prediction shift (a subtle form of overfitting) |
| Symmetric (oblivious) trees | Every node at the same depth uses the same split; trees are balanced, which regularizes and makes inference very fast |
| Strong default parameters | Often competitive without much tuning |
Key hyperparameters
| Parameter | Role |
|---|---|
iterations | Number of trees |
learning_rate | Step size |
depth | Tree depth (typically 4–10) |
l2_leaf_reg | L2 regularization |
loss_function | RMSE, MAE, Quantile:alpha=0.9, Huber:delta=…, and more |
cat_features | Which columns are categorical |
early_stopping_rounds | Stop when validation stops improving |
Example
from catboost import CatBoostRegressor
# Imagine a DataFrame with a categorical column "city"
# cat_cols = ["city"]
cat = CatBoostRegressor(
iterations=2000, learning_rate=0.05, depth=6,
loss_function="RMSE", random_seed=42, verbose=0
)
cat.fit(
X_tr, y_tr,
eval_set=(X_val, y_val),
early_stopping_rounds=50,
# cat_features=cat_cols, # pass raw string categories directly; no one-hot needed
)
print(evaluate(cat))
When to choose it
- Your data has many categorical columns (especially high-cardinality ones).
- You want a strong model with little hyperparameter tuning.
- You need fast prediction at serving time.
XGBoost vs LightGBM vs CatBoost
| XGBoost | LightGBM | CatBoost | |
|---|---|---|---|
| Tree growth | Level-wise (default) | Leaf-wise | Symmetric |
| Speed on big data | Fast | Fastest | Fast (GPU friendly) |
| Categorical handling | Needs encoding (or native option) | Native | Best, native with ordered encoding |
| Overfitting risk on small data | Moderate | Higher (tune num_leaves) | Lower |
| Tuning effort | Moderate | Moderate | Lowest |
18. Quantile Regression
Idea
Ordinary regression predicts the mean of y given X. Quantile regression predicts a chosen quantile (e.g. the median, 10th percentile, or 90th percentile) of y given X.
Instead of "the expected price is ₹50L," you can say "there's a 90% chance the price is below ₹65L."
The loss: pinball (quantile) loss
For quantile level τ (tau, between 0 and 1):
loss = τ · (y − ŷ) if y ≥ ŷ (under-prediction)
loss = (1 − τ) · (ŷ − y) if y < ŷ (over-prediction)
τ = 0.5→ equals median regression (minimizes absolute error)τ = 0.9→ under-predictions are penalized 9× more than over-predictions, so the model learns to predict a high value that ~90% of actual values fall below
Why use it?
| Use case | Explanation |
|---|---|
| Prediction intervals | Fit τ = 0.05 and τ = 0.95 to get a 90% interval |
| Heteroscedastic data | When spread changes with x, intervals widen/narrow accordingly |
| Robustness | Median regression is not pulled by outliers like the mean |
| Risk-focused questions | Worst-case / best-case scenarios (demand planning, delivery times) |
| Non-normal errors | Makes no assumption about the error distribution |
Examples
Linear quantile regression:
from sklearn.linear_model import QuantileRegressor
q_models = {}
for q in [0.05, 0.5, 0.95]:
q_models[q] = QuantileRegressor(quantile=q, alpha=0.0, solver="highs").fit(X_train, y_train)
lower = q_models[0.05].predict(X_test)
median = q_models[0.5].predict(X_test)
upper = q_models[0.95].predict(X_test)
coverage = np.mean((y_test >= lower) & (y_test <= upper))
print(f"Share of test points inside the 90% interval: {coverage:.2%}")
Nonlinear quantile regression with gradient boosting:
gb_q90 = GradientBoostingRegressor(loss="quantile", alpha=0.9, n_estimators=200, max_depth=3)
gb_q90.fit(X_train, y_train)
(statsmodels.regression.quantile_regression.QuantReg also gives coefficient tables and inference, and LightGBM / XGBoost / CatBoost offer quantile objectives too.)
Things to know
- You train a separate model for each quantile.
- Independent models can cross (e.g. predicted 90th percentile below the 80th). Check for it.
- Always verify calibration: a 90% interval should contain about 90% of test points.
19. Robust Regression
Idea
Ordinary least squares squares the errors, so one wild outlier can drag the whole line toward it. Robust regression methods reduce the influence of outliers and bad data points.
OLS line pulled by outlier ╱ ·
╱ · · ← outlier
Robust line stays on trend ───────────
Types of outliers (why the distinction matters)
| Type | Meaning |
|---|---|
| Outlier in y | Unusual response value |
| High-leverage point | Unusual x value; can pull the line strongly |
| Influential point | Removing it noticeably changes the fit |
Common robust methods
| Method | How it works | Good for |
|---|---|---|
| Huber regression | Squared loss for small errors, linear loss for large errors (switch point controlled by epsilon), so outliers have bounded influence | Moderate outlier contamination, mostly in y |
| RANSAC | Repeatedly fits on random small subsets, counts inliers, and keeps the model with the most inliers | Many outliers; clear inlier/outlier separation |
| Theil–Sen | Median of slopes between point pairs | Small data; up to ~29% outliers in simple settings |
| Least Absolute Deviations / Median regression | Minimizes absolute error (see quantile regression at τ = 0.5) | Outliers in y |
M-estimators (e.g. via statsmodels RLM) | Iteratively reweighted least squares with a robust loss (Huber, Tukey bisquare) | General robust fitting with inference |
Example
from sklearn.linear_model import HuberRegressor, RANSACRegressor, TheilSenRegressor, LinearRegression
# Add some outliers to the training target
y_dirty = y_train.copy()
idx = np.random.default_rng(0).choice(len(y_dirty), 25, replace=False)
y_dirty[idx] += np.random.default_rng(1).normal(0, 600, 25)
models = {
"OLS": LinearRegression(),
"Huber": HuberRegressor(epsilon=1.35, max_iter=1000),
"RANSAC": RANSACRegressor(random_state=42),
"Theil-Sen": TheilSenRegressor(random_state=42),
}
for name, m in models.items():
m.fit(X_train, y_dirty)
print(name, evaluate(m)) # evaluated on clean test data
Practical notes
- Standardize features before Huber (it is scale-sensitive).
- Robust regression is not a substitute for investigating outliers: they may be data errors, or genuinely important events.
- Trade-off: robust estimators are slightly less efficient (higher variance) than OLS when the data is perfectly clean.
20. Regression Assumptions and Diagnostics
These checks mainly matter for linear regression (and when you want to interpret coefficients, build confidence intervals, or run hypothesis tests). Tree-based models make far fewer assumptions, but residual analysis is still a useful habit for any regression model.
20.1 The assumptions: remember L.I.N.E. (+ extras)
| Letter | Assumption | Plain meaning | What breaks if violated |
|---|---|---|---|
| L | Linearity | The relationship between features and target is linear (in parameters) | Biased, systematically wrong predictions |
| I | Independence of errors | Residuals aren't correlated with each other (no pattern over time/order) | Standard errors too small; false confidence |
| N | Normality of residuals | Residuals are roughly normally distributed | Mainly affects confidence intervals and p-values (less crucial with large samples) |
| E | Equal variance (homoscedasticity) | Spread of residuals is constant across all fitted values | Inefficient estimates; wrong standard errors |
| + | No (severe) multicollinearity | Features aren't near-duplicates of each other | Unstable, hard-to-interpret coefficients |
| + | No highly influential outliers | A few points don't control the fit | Distorted coefficients |
| + | Features not measured with large error / no omitted key variables | Model is correctly specified | Biased coefficients |
What really matters for prediction? Linearity and influential points affect accuracy the most. Normality is the least critical for point predictions.
20.2 Residual plots: your main diagnostic tool
Let residual = actual − predicted.
| Plot | What to look for | Healthy | Problem |
|---|---|---|---|
| Residuals vs Fitted | Pattern in residuals | Random cloud around 0 | Curve → nonlinearity; funnel → heteroscedasticity |
| Q–Q plot of residuals | Points vs the diagonal line | Points on the line | S-shape / heavy ends → non-normal residuals |
| Scale–Location (√ | standardized residual | vs fitted) | Trend in spread |
| Residuals vs Leverage | Points with high leverage and large residuals (with Cook's distance contours) | No points far out | Points in a corner = influential |
| Residuals vs Order / time | Runs or cycles | No pattern | Pattern → autocorrelation |
Healthy Nonlinear Heteroscedastic
· · · · · · · ·
─·─·──·──·─ 0 · ────── · ───·──·─·─ 0 · ·
· · · · · · · · · ·
(random cloud) (U-shape/curve) (funnel / fan shape)
20.3 Running diagnostics in code
import statsmodels.api as sm
from statsmodels.stats.diagnostic import het_breuschpagan, het_white
from statsmodels.stats.stattools import durbin_watson
from statsmodels.stats.outliers_influence import variance_inflation_factor
from scipy import stats
X_sm = sm.add_constant(X_train)
ols = sm.OLS(y_train, X_sm).fit()
print(ols.summary()) # coefficients, p-values, R², F-test, Durbin-Watson, etc.
resid = ols.resid
fitted = ols.fittedvalues
Plots:
fig, ax = plt.subplots(1, 3, figsize=(16, 4))
# Residuals vs fitted
ax[0].scatter(fitted, resid, alpha=0.5); ax[0].axhline(0, color="red")
ax[0].set(xlabel="Fitted", ylabel="Residuals", title="Residuals vs Fitted")
# Q-Q plot
sm.qqplot(resid, line="45", fit=True, ax=ax[1]); ax[1].set_title("Q-Q Plot")
# Histogram of residuals
ax[2].hist(resid, bins=30); ax[2].set_title("Residual distribution")
plt.show()
20.4 Formal tests
| Assumption | Test | Null hypothesis (H₀) | Reject H₀ (p < 0.05) means |
|---|---|---|---|
| Homoscedasticity | Breusch–Pagan, White | Constant variance | Heteroscedasticity present |
| Normality of residuals | Shapiro–Wilk, Jarque–Bera, D'Agostino | Residuals are normal | Residuals not normal |
| Independence / autocorrelation | Durbin–Watson | No first-order autocorrelation | DW near 2 is fine; near 0 → positive, near 4 → negative autocorrelation |
| Linearity / specification | Ramsey RESET | Model correctly specified | Missing nonlinear terms |
| Multicollinearity | VIF (not a hypothesis test) | N/A | VIF > 5–10 is a concern |
# Homoscedasticity
bp_lm, bp_p, _, _ = het_breuschpagan(resid, X_sm)
print("Breusch-Pagan p-value:", bp_p)
# Normality of residuals
print("Shapiro-Wilk p-value:", stats.shapiro(resid).pvalue)
# Autocorrelation
print("Durbin-Watson:", durbin_watson(resid))
# Multicollinearity (VIF), skipping the constant
vif = pd.Series(
[variance_inflation_factor(X_sm, i) for i in range(1, X_sm.shape[1])],
index=[f"x{i}" for i in range(X_train.shape[1])]
)
print(vif)
Caution: with very large samples tests flag tiny, harmless violations; with tiny samples they miss real ones. Use tests together with plots.
20.5 Outliers, leverage and influence
| Concept | Meaning | Common flag |
|---|---|---|
| Standardized / studentized residual | Residual scaled by its standard error | |value| > 3 → outlier in y |
Leverage (hat value hᵢᵢ) | How unusual a point's x values are | > 2(p+1)/n |
| Cook's distance | Overall influence of a point on all fitted values | > 4/n (or > 1, more lenient) |
| DFFITS / DFBETAS | Influence on its own prediction / on each coefficient | Large values |
infl = ols.get_influence()
cooks_d = infl.cooks_distance[0]
leverage = infl.hat_matrix_diag
stud_res = infl.resid_studentized_external
n, p = X_sm.shape
flagged = np.where(cooks_d > 4 / n)[0]
print("Potentially influential rows:", flagged)
20.6 What to do when assumptions fail
| Problem | Possible fixes |
|---|---|
| Nonlinearity | Add polynomial/interaction terms, transform variables (log, sqrt), use splines, or switch to nonlinear / tree-based models |
| Heteroscedasticity | Transform y (log, Box–Cox), weighted least squares, robust (heteroscedasticity-consistent) standard errors, or quantile regression |
| Non-normal residuals | Transform y; check outliers; with large samples it often matters little; consider robust methods |
| Autocorrelation | Add lagged variables or time-trend features; use time-series methods or generalized least squares |
| Multicollinearity | Drop/merge correlated features, use Ridge / Elastic Net, or PCA |
| Influential outliers | Investigate for data errors; use robust regression; report results with and without them |
| Missing important variables | Add relevant features; revisit the problem setup |
20.7 A diagnostic checklist
- Plot the data first (scatter plots of target vs features)
- Fit the model; check R² / RMSE on held-out data
- Residuals vs fitted: random scatter around 0?
- Q–Q plot: residuals close to the line?
- Scale–location / Breusch–Pagan: constant spread?
- Durbin–Watson / residuals vs order (if data has an order or time component)
- VIF: any value above 5–10?
- Cook's distance / leverage: any dominant points?
- Decide on fixes, refit, and compare
Model Comparison Cheat Sheet
| Model | Handles nonlinearity | Feature selection | Needs scaling | Interpretability | Outlier robustness | Typical strength |
|---|---|---|---|---|---|---|
| Simple Linear | No | n/a | No | ★★★★★ | Low | Baseline, one feature |
| Multiple Linear | No | No | Helpful | ★★★★★ | Low | Interpretable baseline |
| Polynomial | Yes (smooth curves) | No | Yes | ★★★ | Low | Mild curvature |
| Nonlinear (parametric) | Yes (by design) | n/a | Sometimes | ★★★★ | Low | Known scientific form |
| Ridge | No | No (shrinks) | Yes | ★★★★ | Low | Multicollinearity |
| Lasso | No | Yes | Yes | ★★★★ | Low | Sparse models |
| Elastic Net | No | Yes | Yes | ★★★★ | Low | Correlated groups + sparsity |
| Bayesian | No | (ARD: yes) | Helpful | ★★★★ | Low | Uncertainty, small data |
| SVR | Yes (kernels) | No | Yes | ★★ | Medium | Medium-size, nonlinear |
| Decision Tree | Yes | Implicit | No | ★★★★ | Medium | Explainable rules |
| Random Forest | Yes | Implicit | No | ★★ | Medium–High | Strong default |
| Extra Trees | Yes | Implicit | No | ★★ | Medium–High | Fast, low variance |
| Gradient Boosting | Yes | Implicit | No | ★★ | Medium (loss-dependent) | High accuracy |
| AdaBoost | Yes | Implicit | No | ★★ | Low | Simple boosting |
| XGBoost | Yes | Implicit | No | ★★ | Medium | Top tabular accuracy |
| LightGBM | Yes | Implicit | No | ★★ | Medium | Large data, speed |
| CatBoost | Yes | Implicit | No | ★★ | Medium | Categorical data, low tuning |
| Quantile | Depends on base model | Depends | Depends | ★★★–★★★★ | High (median) | Intervals, skewed/uneven noise |
| Robust (Huber/RANSAC/Theil–Sen) | No | No | Yes (Huber) | ★★★★ | High | Data with outliers |
How to choose (quick guide)
Need to explain coefficients / do inference?
└─► Linear family → check assumptions (section 20)
Many features, multicollinearity, or overfitting?
└─► Ridge / Lasso / Elastic Net
Need uncertainty on predictions?
└─► Bayesian regression or Quantile regression
Data has outliers?
└─► Robust regression (Huber / RANSAC / Theil–Sen) or median regression
Known scientific curve?
└─► Nonlinear regression
Complex tabular data, best accuracy?
└─► Random Forest / Extra Trees → Gradient Boosting family
• many categorical features → CatBoost
• very large data / speed → LightGBM
• robust all-rounder → XGBoost
Key Takeaways
- Start simple: a linear model is your baseline and your sanity check.
- Regularization (Ridge, Lasso, Elastic Net) fixes overfitting and multicollinearity in linear models; Lasso and Elastic Net also select features.
- Polynomial regression is linear in parameters; nonlinear regression is not, and needs iterative fitting plus good starting values.
- Bagging (Random Forest, Extra Trees) lowers variance; boosting (Gradient Boosting, AdaBoost, XGBoost, LightGBM, CatBoost) lowers bias step by step, so tune the learning rate and use early stopping.
- Tree-based models cannot extrapolate beyond the range of the training targets.
- Quantile regression gives ranges, Bayesian regression gives uncertainty, robust regression resists outliers.
- Always run diagnostics: residual plots first, tests second.
- Evaluate on unseen data, tune with cross-validation, and keep preprocessing inside a pipeline.
#Supervised Learning: Classification#
Classification = predicting a category. Spam or not spam, disease or healthy, which digit is this, which genres does a movie belong to. You show the model many labelled examples, and it learns a rule that assigns the right label to new, unseen data.
These notes cover the most important classification algorithms, from simple probabilistic models to boosted trees and ensembles, and then the different types of classification problems (binary, multiclass, multilabel, ordinal).
Table of Contents
- Foundations: the classification setup
- Logistic Regression
- K-Nearest Neighbors (KNN)
- Naive Bayes Classifier
- Decision Tree Classification
- Random Forest Classification
- Support Vector Machines (SVM)
- Linear Discriminant Analysis (LDA)
- Quadratic Discriminant Analysis (QDA)
- AdaBoost Classification
- Gradient Boosting Classification
- XGBoost Classification
- LightGBM Classification
- CatBoost Classification
- Extra Trees Classifier
- Stacking and Voting Classifiers
- Binary Classification
- Multiclass Classification
- Multilabel Classification
- Ordinal Classification
- Model Comparison Cheat Sheet
0. Foundations: the classification setup
The big picture
Features X ──► Classifier ──► Class probabilities ──► Predicted label
e.g. [0.1, 0.7, 0.2] (here: class 1)
- Features (X): input columns.
- Target (y): a discrete label (the class).
- Most classifiers actually output probabilities for each class, then pick the largest (or apply a threshold).
- Decision boundary: the surface in feature space that separates one predicted class from another.
Generative vs discriminative (useful framing)
| Type | Learns | Examples in these notes |
|---|---|---|
| Discriminative | `P(class | x)` directly, or the boundary itself |
| Generative | How each class generates data, `P(x | class)andP(class)`, then uses Bayes' theorem |
Common setup for the code examples
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split, cross_val_score, GridSearchCV
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import make_pipeline
from sklearn.metrics import (accuracy_score, precision_score, recall_score, f1_score,
roc_auc_score, confusion_matrix, classification_report)
# Synthetic binary dataset with some imbalance (70% / 30%)
X, y = make_classification(n_samples=1000, n_features=10, n_informative=6,
n_redundant=2, weights=[0.7, 0.3], random_state=42)
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, stratify=y, random_state=42 # stratify keeps class ratios
)
def evaluate(model, X_te=X_test, y_te=y_test):
pred = model.predict(X_te)
out = {
"Accuracy": accuracy_score(y_te, pred),
"Precision": precision_score(y_te, pred),
"Recall": recall_score(y_te, pred),
"F1": f1_score(y_te, pred),
}
if hasattr(model, "predict_proba"):
out["ROC-AUC"] = roc_auc_score(y_te, model.predict_proba(X_te)[:, 1])
return out
Golden rules: split with
stratify=y, keep scaling inside aPipeline(so no test data leaks into training), and never judge a classifier by accuracy alone (see section 16).
1. Logistic Regression
Idea
Despite its name, logistic regression is a classification algorithm. It computes a linear score from the features and squeezes it through the sigmoid function to get a probability between 0 and 1.
z = β₀ + β₁x₁ + β₂x₂ + … + βₚxₚ (linear score, also called log-odds)
P(y = 1 | x) = 1 / (1 + e^(−z)) (sigmoid)
Probability
1 │ ________
│ __/
0.5 │ - - - - /- - - - - - ← default threshold at 0.5
│ __/
0 │_____/
└──────────────────────► z (linear score)
Odds and log-odds
odds = p / (1 − p)
log-odds = ln( p / (1 − p) ) = β₀ + β₁x₁ + …
So the model is linear in the log-odds. This gives an interpretation for coefficients:
e^βⱼis the odds ratio: how much the odds of the positive class multiply whenxⱼincreases by one unit, holding the others constant.
How it learns: log loss (cross-entropy)
Loss = −[ y·ln(p) + (1 − y)·ln(1 − p) ] averaged over all samples
Confident wrong predictions are punished very heavily. No closed-form solution, so it is solved iteratively (e.g. L-BFGS, gradient-based solvers).
Regularization
scikit-learn applies L2 regularization by default. The parameter C is the inverse of regularization strength:
C | Effect |
|---|---|
| Small | Strong regularization → simpler model, risk of underfitting |
| Large | Weak regularization → closer to the unpenalized fit, risk of overfitting |
Penalty options: l2 (default), l1 (sparse, selects features), elasticnet, or none.
Multiclass
For more than two classes, it uses the softmax function (multinomial logistic regression), producing one probability per class that sums to 1.
Example
from sklearn.linear_model import LogisticRegression
logreg = make_pipeline(StandardScaler(), LogisticRegression(C=1.0, max_iter=1000))
logreg.fit(X_train, y_train)
print(evaluate(logreg))
# Coefficients and odds ratios
coefs = logreg[-1].coef_[0]
odds_ratios = np.exp(coefs)
Characteristics
| Strengths | Limits |
|---|---|
| Interpretable (odds ratios) | Linear decision boundary only |
| Gives well-behaved probabilities | Struggles with complex nonlinear patterns unless you add features |
| Fast, strong baseline | Sensitive to multicollinearity and (without regularization) to separation |
| Works well with scaling + regularization | Needs feature scaling for regularized solvers |
2. K-Nearest Neighbors (KNN)
Idea
KNN classifies a new point by looking at its K closest training points and taking a majority vote.
○ ○ New point ★ with K = 3
○ ● ★ Nearest 3: ● ● ○ → predicted class = ● (2 votes vs 1)
● ●
A "lazy" learner
KNN does no real training: it just stores the data. All the work happens at prediction time (computing distances), so prediction can be slow on big datasets.
Distance metrics
| Metric | Formula idea | Use |
|---|---|---|
| Euclidean (p=2) | Straight-line distance | Default |
| Manhattan (p=1) | Sum of absolute differences | High-dimensional / grid-like data |
| Minkowski | Generalization with parameter p | Flexible |
| Cosine, Hamming, ... | Direction / mismatches | Text, binary features |
Choosing K
| K | Behaviour |
|---|---|
| Small (e.g. 1) | Very flexible, noisy boundary → overfits |
| Large | Smooth boundary → underfits (blurs small classes) |
Pick K by cross-validation. For binary problems, an odd K avoids ties.
Key hyperparameters
| Parameter | Effect |
|---|---|
n_neighbors | The K |
weights | "uniform" (all votes equal) or "distance" (closer neighbours count more) |
metric, p | Distance definition |
algorithm | brute, kd_tree, ball_tree (search speed-ups) |
Example
from sklearn.neighbors import KNeighborsClassifier
knn = make_pipeline(StandardScaler(), KNeighborsClassifier(n_neighbors=7, weights="distance"))
knn.fit(X_train, y_train)
print(evaluate(knn))
# Pick K by cross-validation
for k in [1, 3, 5, 7, 11, 15, 21]:
pipe = make_pipeline(StandardScaler(), KNeighborsClassifier(n_neighbors=k))
print(k, cross_val_score(pipe, X_train, y_train, cv=5, scoring="f1").mean().round(3))
Things to know
- Feature scaling is essential: a feature with a big numeric range would dominate the distance.
- Curse of dimensionality: in many dimensions, all points look equally far away, so KNN degrades. Reduce features or avoid KNN in very high dimensions.
- Irrelevant features hurt it, since they add noise to the distances.
- Memory-heavy (stores all data), slow predictions on large data.
- Non-parametric: makes no assumption about the data's shape; can learn very irregular boundaries.
3. Naive Bayes Classifier
Idea
Naive Bayes uses Bayes' theorem to compute the probability of each class given the features, then picks the most probable one.
P(x | C) · P(C)
P(C | x) = ─────────────────
P(x)
P(C): prior, how common the class isP(x | C): likelihood, how likely these features are within that classP(C | x): posterior, what we want
The "naive" assumption
It assumes all features are conditionally independent given the class, which turns a hard joint probability into a simple product:
P(x₁, x₂, …, xₚ | C) = P(x₁|C) · P(x₂|C) · … · P(xₚ|C)
Prediction = argmax over C of P(C) · Π P(xᵢ | C)
This assumption is rarely true in real data, yet Naive Bayes often still classifies well, because it only needs to rank the classes correctly, not estimate probabilities perfectly.
Variants (choose by feature type)
| Variant | Feature type | Models P(xᵢ | C) as |
|---|---|---|
| GaussianNB | Continuous | Normal distribution per class |
| MultinomialNB | Counts (e.g. word counts) | Multinomial distribution |
| BernoulliNB | Binary (word present/absent) | Bernoulli distribution |
| ComplementNB | Counts, imbalanced text | Uses the complement of each class; better on imbalanced data |
| CategoricalNB | Categorical features | Category frequencies per class |
Laplace (additive) smoothing
If a feature value never appeared with a class in training, its probability would be 0 and wipe out the whole product. Smoothing (alpha, typically 1) adds a small count to everything to avoid zeros.
Example
from sklearn.naive_bayes import GaussianNB, MultinomialNB
from sklearn.feature_extraction.text import CountVectorizer
gnb = GaussianNB().fit(X_train, y_train)
print(evaluate(gnb))
# Text example with MultinomialNB
text_clf = make_pipeline(CountVectorizer(), MultinomialNB(alpha=1.0))
# text_clf.fit(list_of_documents, labels)
Characteristics
| Strengths | Limits |
|---|---|
| Extremely fast; trains in one pass | Independence assumption is unrealistic |
| Works with little data and many features | Predicted probabilities are often poorly calibrated (too extreme) |
| Great for text (spam, sentiment) | Can't capture feature interactions |
| Few hyperparameters | GaussianNB assumes normal-shaped features |
4. Decision Tree Classification
Idea
A decision tree predicts by asking a sequence of yes/no questions about the features. Each leaf gives a class (the majority class of the training samples in it) and a probability (the class proportions in it).
Income > 50k?
/ \
Yes No
Age < 30? Has_debt?
/ \ / \
Approve Review Reject Review ← leaves = predicted classes
How splits are chosen: impurity measures
The tree picks the split that makes the child nodes as pure (single-class) as possible.
| Measure | Formula | Notes |
|---|---|---|
| Gini impurity | 1 − Σ pₖ² | Default in scikit-learn; fast |
| Entropy | −Σ pₖ · log₂(pₖ) | Based on information theory |
| Information gain | Entropy(parent) − weighted Entropy(children) | The drop in entropy from a split |
pₖ is the share of class k in the node. A node with 50/50 classes has maximum impurity; a 100/0 node has impurity 0.
Gini and entropy usually give very similar trees.
Key hyperparameters
| Parameter | Effect |
|---|---|
max_depth | Main overfitting control |
min_samples_split / min_samples_leaf | Minimum samples to split / to keep in a leaf |
max_features | Features considered per split |
criterion | gini, entropy, log_loss |
class_weight | "balanced" helps with imbalanced classes |
ccp_alpha | Cost-complexity (post) pruning |
Example
from sklearn.tree import DecisionTreeClassifier, plot_tree
dt = DecisionTreeClassifier(max_depth=4, min_samples_leaf=10, random_state=42)
dt.fit(X_train, y_train)
print(evaluate(dt))
plt.figure(figsize=(14, 6))
plot_tree(dt, max_depth=2, filled=True, class_names=["0", "1"])
plt.show()
Characteristics
| Strengths | Limits |
|---|---|
| Easy to visualize and explain as rules | High variance: unstable, overfits easily if unpruned |
| No scaling needed | Axis-aligned splits struggle with diagonal boundaries |
| Handles nonlinearity and interactions | Greedy construction isn't globally optimal |
| Works with mixed feature types | Probabilities from leaves are coarse |
5. Random Forest Classification
Idea
Train many decision trees on different random versions of the data and let them vote. The final probability is the average of the trees' probabilities.
Data ─► Bootstrap 1 ─► Tree 1 ─┐
─► Bootstrap 2 ─► Tree 2 ─┼─► Majority vote / average probability ─► Class
─► Bootstrap n ─► Tree n ─┘
Two sources of randomness make the trees different (decorrelated):
- Bagging: each tree is trained on a bootstrap sample (rows drawn with replacement).
- Random feature subsets: each split considers only a random subset of features (default
sqrt(n_features)for classification).
Averaging many noisy-but-different trees drops variance sharply while keeping low bias.
Key hyperparameters
| Parameter | Effect |
|---|---|
n_estimators | Number of trees (more is better, with diminishing returns) |
max_features | Features per split (smaller → more diverse trees) |
max_depth, min_samples_leaf | Per-tree complexity |
class_weight | "balanced" or "balanced_subsample" for imbalance |
oob_score | Free validation estimate using left-out rows |
Example
from sklearn.ensemble import RandomForestClassifier
rf = RandomForestClassifier(n_estimators=300, min_samples_leaf=2, oob_score=True,
class_weight="balanced", n_jobs=-1, random_state=42)
rf.fit(X_train, y_train)
print("OOB accuracy:", rf.oob_score_)
print(evaluate(rf))
importances = pd.Series(rf.feature_importances_,
index=[f"x{i}" for i in range(X.shape[1])]).sort_values(ascending=False)
Characteristics
| Strengths | Limits |
|---|---|
| Strong performance with little tuning | Less interpretable than one tree |
| Resists overfitting as trees are added | Larger and slower than simple models |
| Handles nonlinearity, interactions, mixed types | Probabilities can be less calibrated |
| Gives feature importance and OOB estimate | Impurity-based importance can favour high-cardinality features (permutation importance is more reliable) |
6. Support Vector Machines (SVM)
Idea
An SVM finds the separating boundary (hyperplane) with the largest margin, which is the widest possible "street" between the two classes.
○ ○ ┆ ● ●
○ ○ ○ ┆ ← margin → ● ●
○ ┌──────────────┼──────────────┐ ●
support vectors (points touching the street) define the boundary
- Support vectors: the training points closest to the boundary. Only these determine the model; the rest could be removed without changing it.
- Large margin → better generalization.
Soft margin and C
Real data overlaps, so SVM allows some mistakes:
C | Behaviour |
|---|---|
| Small | Wide margin, tolerates more misclassifications → simpler, may underfit |
| Large | Narrow margin, tries to classify everything correctly → may overfit |
The kernel trick (for nonlinear boundaries)
A kernel implicitly maps data into a higher-dimensional space where a straight boundary may work, without actually computing that space.
| Kernel | Boundary | Notes |
|---|---|---|
linear | Straight | Fast; good for high-dimensional / text data |
poly | Polynomial curve | Degree parameter |
rbf (Gaussian) | Flexible, smooth, local | Most common default |
sigmoid | S-shaped | Rarely best |
For RBF, gamma controls reach: large gamma = each point influences only its close neighbours → wiggly boundary (overfit); small gamma = smoother (underfit).
Example
from sklearn.svm import SVC
svm = make_pipeline(
StandardScaler(), # scaling is required
SVC(kernel="rbf", C=1.0, gamma="scale", probability=True, class_weight="balanced")
)
svm.fit(X_train, y_train)
print(evaluate(svm))
Practical notes
- Always scale features.
SVCoutputs a decision score by default.probability=Truefits an extra calibration step (Platt scaling) using internal cross-validation, so it makes training slower.- Multiclass:
SVCuses one-vs-one internally;LinearSVCuses one-vs-rest. - For large datasets with a linear boundary, use
LinearSVCorSGDClassifier; kernel SVMs get slow as rows grow.
| Strengths | Limits |
|---|---|
| Effective in high dimensions | Slow on large datasets |
| Flexible via kernels | Needs scaling and careful tuning (C, gamma) |
| Memory-efficient (support vectors only) | Not directly probabilistic; hard to interpret |
7. Linear Discriminant Analysis (LDA)
Idea
LDA is a generative classifier. It assumes that within each class, the features follow a multivariate normal (Gaussian) distribution, and that all classes share the same covariance matrix (same spread and shape; only the means differ). Applying Bayes' theorem to this gives a linear decision boundary.
Class A: N(μ_A, Σ) same Σ for every class
Class B: N(μ_B, Σ) → boundary is a straight line / plane
LDA also reduces dimensions
LDA can project the data onto the directions that maximize the separation between class means relative to the spread within classes.
- At most
min(n_classes − 1, n_features)components. - Unlike PCA (which ignores labels), LDA is supervised: it uses the labels to find useful axes.
Key points
| Item | Notes |
|---|---|
| Assumptions | Gaussian classes, equal covariance across classes |
solver | svd (default, no covariance computed), lsqr, eigen |
shrinkage | Regularizes the covariance estimate (use with lsqr/eigen); helps when features outnumber samples or are correlated |
priors | Class priors; default from training frequencies |
Example
from sklearn.discriminant_analysis import LinearDiscriminantAnalysis
lda = LinearDiscriminantAnalysis(solver="lsqr", shrinkage="auto")
lda.fit(X_train, y_train)
print(evaluate(lda))
# As a supervised dimensionality reducer (multiclass data gives more components)
lda_proj = LinearDiscriminantAnalysis(n_components=1).fit_transform(X_train, y_train)
| Strengths | Limits |
|---|---|
| Fast, simple, closed-form | Boundary must be linear |
| Works well when assumptions roughly hold | Sensitive to non-Gaussian data and outliers |
| Gives probabilities and dimension reduction | Equal-covariance assumption is restrictive |
| Stable with small samples (esp. with shrinkage) |
8. Quadratic Discriminant Analysis (QDA)
Idea
QDA is LDA with one assumption relaxed: each class gets its own covariance matrix. Classes can have different spreads and shapes, which makes the decision boundary curved (quadratic): ellipses, parabolas, hyperbolas.
Class A: N(μ_A, Σ_A)
Class B: N(μ_B, Σ_B) different Σ → curved boundary
LDA vs QDA
| LDA | QDA | |
|---|---|---|
| Covariance | One shared matrix | One per class |
| Boundary | Linear | Quadratic (curved) |
| Parameters to estimate | Fewer | Many more (≈ one covariance per class) |
| Bias | Higher | Lower |
| Variance | Lower | Higher |
| Best when | Small data; classes have similar shape | Enough data; classes clearly differ in spread |
This is a bias-variance tradeoff: QDA is more flexible but needs more data per class to estimate covariances reliably.
Example
from sklearn.discriminant_analysis import QuadraticDiscriminantAnalysis
qda = QuadraticDiscriminantAnalysis(reg_param=0.1) # regularizes covariances
qda.fit(X_train, y_train)
print(evaluate(qda))
reg_param shrinks each class covariance toward the identity matrix. It is useful when you have many features or few samples per class.
| Strengths | Limits |
|---|---|
| Captures curved boundaries | Needs more data per class |
| Handles classes with different shapes | Still assumes Gaussian class distributions |
| Closed-form, fast | Covariance estimates can be unstable in high dimensions |
9. AdaBoost Classification
Idea
AdaBoost (Adaptive Boosting) builds a strong classifier from many weak learners (typically decision stumps, trees of depth 1), trained one after another. After each round, the samples that were misclassified get higher weights, so the next learner is forced to focus on the hard cases.
The loop in plain words
1. Give every training sample the same weight.
2. Train a weak learner on the weighted data.
3. Compute its weighted error rate.
4. Give the learner a "say" (vote weight): more accurate → bigger say.
5. Increase the weight of misclassified samples, decrease the rest.
6. Repeat; the final prediction is the weighted vote of all learners.
For multiclass problems, scikit-learn uses the SAMME extension of AdaBoost.
Key hyperparameters
| Parameter | Effect |
|---|---|
estimator | The weak learner (default: a depth-1 tree) |
n_estimators | Number of rounds |
learning_rate | Shrinks each learner's contribution; trades off with n_estimators |
Example
from sklearn.ensemble import AdaBoostClassifier
from sklearn.tree import DecisionTreeClassifier
ada = AdaBoostClassifier(
estimator=DecisionTreeClassifier(max_depth=1),
n_estimators=200, learning_rate=0.5, random_state=42
)
ada.fit(X_train, y_train)
print(evaluate(ada))
| Strengths | Limits |
|---|---|
| Simple and effective with weak learners | Sensitive to noisy labels and outliers: it keeps boosting their weight |
| Few hyperparameters | Sequential, so not easily parallelized |
| Less prone to overfit on clean data | Usually outperformed by gradient boosting libraries |
10. Gradient Boosting Classification
Idea
Gradient boosting builds trees sequentially, with each new tree correcting the errors of the current ensemble. Instead of re-weighting samples (AdaBoost), it fits each new tree to the gradient of the loss function with respect to the current predictions.
Score(x) = F₀ + η·T₁(x) + η·T₂(x) + … + η·T_M(x)
P(y=1 | x) = sigmoid( Score(x) )
- The trees work on the log-odds scale, then the sigmoid turns scores into probabilities.
- The loss is log loss (cross-entropy).
- Each tree is small and shallow;
η(learning rate) keeps each step cautious.
Binary vs multiclass
- Binary: one tree is added per round.
- Multiclass with
Kclasses:Ktrees are added per round (one per class), combined with softmax, so it is K times more expensive.
Key hyperparameters
| Parameter | Effect |
|---|---|
n_estimators | Number of boosting rounds |
learning_rate | Smaller = more careful (needs more trees) |
max_depth | Depth of each tree (usually 3–5) |
subsample | Row fraction per tree; < 1 gives stochastic gradient boosting (less overfitting) |
n_iter_no_change | Early stopping patience |
Example
from sklearn.ensemble import GradientBoostingClassifier
gbc = GradientBoostingClassifier(
n_estimators=500, learning_rate=0.05, max_depth=3, subsample=0.8,
n_iter_no_change=20, validation_fraction=0.1, random_state=42
)
gbc.fit(X_train, y_train)
print("Rounds used:", gbc.n_estimators_)
print(evaluate(gbc))
For larger datasets, scikit-learn's
HistGradientBoostingClassifieris much faster (it bins features into histograms) and handles missing values natively.
Characteristics
- Often among the most accurate methods on tabular data.
- Unlike random forests, too many rounds will overfit, so use early stopping.
- Training is sequential and slower; it needs more tuning than a forest.
11. XGBoost Classification
Idea
XGBoost is a fast, heavily engineered, regularized implementation of gradient-boosted trees. It adds penalties on tree complexity to the usual objective and uses second-order (curvature) information to take better steps.
Objective = Σ loss(yᵢ, ŷᵢ) + Σ Ω(tree)
Ω(tree) = γ·(number of leaves) + ½·λ·Σ(leaf weights)² (+ α·Σ|leaf weights|)
What makes it stand out
| Feature | Benefit |
|---|---|
Built-in L1/L2 regularization and gamma pruning | Less overfitting |
| Sparsity-aware splits | Handles missing values natively |
| Parallel, cache-aware implementation | Fast |
| Early stopping | Stops at the best round automatically |
Objectives for classification
| Task | objective |
|---|---|
| Binary | binary:logistic (outputs probability) |
| Multiclass (probabilities) | multi:softprob |
| Multiclass (labels only) | multi:softmax |
Key hyperparameters
| Parameter | Role |
|---|---|
n_estimators, learning_rate | Rounds and step size (small rate + many rounds + early stopping) |
max_depth, min_child_weight | Tree complexity / conservativeness |
subsample, colsample_bytree | Row and column sampling |
gamma, reg_lambda, reg_alpha | Regularization |
scale_pos_weight | Imbalance: set to (# negatives) / (# positives) for binary |
Example
from xgboost import XGBClassifier
X_tr, X_val, y_tr, y_val = train_test_split(X_train, y_train, test_size=0.2,
stratify=y_train, random_state=42)
xgb = XGBClassifier(
n_estimators=2000, learning_rate=0.05, max_depth=4,
subsample=0.8, colsample_bytree=0.8,
scale_pos_weight=(y_tr == 0).sum() / (y_tr == 1).sum(),
eval_metric="logloss", early_stopping_rounds=50,
random_state=42, n_jobs=-1
)
xgb.fit(X_tr, y_tr, eval_set=[(X_val, y_val)], verbose=False)
print("Best iteration:", xgb.best_iteration)
print(evaluate(xgb))
Labels for XGBoost's scikit-learn interface should be integers starting at 0 (use
LabelEncoderif needed). No feature scaling is required.
12. LightGBM Classification
Idea
LightGBM is a gradient-boosting framework tuned for speed and low memory, particularly on large datasets with many features.
What makes it different
| Innovation | What it does |
|---|---|
| Histogram-based splitting | Buckets continuous features into bins, so split finding is fast and light |
| Leaf-wise growth | Always splits the leaf with the largest loss reduction (best-first), instead of growing level by level |
| GOSS | Keeps samples with large gradients, subsamples those with small gradients |
| EFB | Bundles mutually exclusive sparse features |
| Native categorical support | No one-hot encoding needed |
Level-wise growth Leaf-wise growth (LightGBM)
● ●
● ● ● ○
● ● ● ● ● ●
(expands every level) (expands only the best leaf)
Caveat: leaf-wise growth can create deep, lopsided trees and overfit on small datasets. Control it with num_leaves, max_depth, and min_child_samples.
Key hyperparameters
| Parameter | Role |
|---|---|
num_leaves | Main complexity control (keep below 2^max_depth) |
min_child_samples | Minimum samples per leaf; raise it to reduce overfitting |
learning_rate, n_estimators | Step size and rounds |
subsample + subsample_freq, colsample_bytree | Row / column sampling |
reg_alpha, reg_lambda | L1 / L2 regularization |
class_weight, is_unbalance, scale_pos_weight | Handling imbalance |
Example
import lightgbm as lgb
lgbm = lgb.LGBMClassifier(
n_estimators=2000, learning_rate=0.05, num_leaves=31, min_child_samples=20,
subsample=0.8, subsample_freq=1, colsample_bytree=0.8,
class_weight="balanced", random_state=42, verbose=-1
)
lgbm.fit(X_tr, y_tr, eval_set=[(X_val, y_val)],
callbacks=[lgb.early_stopping(50, verbose=False)])
print(evaluate(lgbm))
Choose it when: you have large data, many features, or many categorical columns, and need fast training. On small datasets, tune num_leaves and min_child_samples carefully.
13. CatBoost Classification
Idea
CatBoost is a gradient-boosting library designed to handle categorical features natively and to deliver strong results with very little tuning.
What makes it different
| Innovation | What it does |
|---|---|
| Ordered target statistics | Encodes a category using the target mean of only earlier rows (in a random order), which prevents target leakage |
| Ordered boosting | Computes each row's gradient using a model that didn't see that row, reducing prediction shift |
| Symmetric (oblivious) trees | All nodes at one depth use the same split; balanced trees act as regularization and predict very fast |
| Strong defaults | Often competitive without much tuning |
Key hyperparameters
| Parameter | Role |
|---|---|
iterations, learning_rate | Rounds and step size |
depth | Tree depth (typically 4–10) |
l2_leaf_reg | L2 regularization |
loss_function | Logloss (binary), MultiClass |
auto_class_weights | "Balanced" or "SqrtBalanced" for imbalance |
cat_features | Indices or names of categorical columns |
Example
from catboost import CatBoostClassifier
cat = CatBoostClassifier(
iterations=2000, learning_rate=0.05, depth=6,
loss_function="Logloss", auto_class_weights="Balanced",
random_seed=42, verbose=0
)
cat.fit(
X_tr, y_tr,
eval_set=(X_val, y_val),
early_stopping_rounds=50,
# cat_features=["city", "device"], # pass raw string categories directly
)
print(evaluate(cat))
Choose it when: you have many categorical features (especially high-cardinality ones), want strong results with minimal tuning, or need fast inference.
XGBoost vs LightGBM vs CatBoost
| XGBoost | LightGBM | CatBoost | |
|---|---|---|---|
| Tree growth | Level-wise (default) | Leaf-wise | Symmetric |
| Speed on large data | Fast | Fastest | Fast (GPU friendly) |
| Categorical features | Needs encoding (native support is optional) | Native | Best, with ordered encoding |
| Overfitting risk on small data | Moderate | Higher | Lower |
| Tuning effort | Moderate | Moderate | Lowest |
14. Extra Trees Classifier
Idea
Extremely Randomized Trees are like a random forest with one extra dose of randomness: at each split, instead of searching for the best threshold for each candidate feature, Extra Trees draws thresholds at random and keeps the best of those random splits.
Differences from Random Forest
| Random Forest | Extra Trees | |
|---|---|---|
| Row sampling | Bootstrap (default) | Whole dataset by default (bootstrap=False) |
| Split thresholds | Optimized | Random |
| Variance | Low | Even lower |
| Bias | Lower | Slightly higher |
| Training speed | Slower | Faster (no threshold search) |
The extra randomness makes the trees less correlated and the final boundary smoother, which acts as a mild regularizer, especially useful on noisy data.
Example
from sklearn.ensemble import ExtraTreesClassifier
et = ExtraTreesClassifier(n_estimators=300, min_samples_leaf=2,
class_weight="balanced", n_jobs=-1, random_state=42)
et.fit(X_train, y_train)
print(evaluate(et))
When to try it: you want forest-like accuracy but faster training, or you suspect high variance. Compare it with Random Forest by cross-validation; neither always wins.
15. Stacking and Voting Classifiers
Both are ensembles of different model types. The idea: models make different mistakes, so combining them can beat any single one. This works best when the models are diverse (e.g. a linear model + a tree ensemble + KNN).
15.1 Voting classifier
Several models each make a prediction, then the results are combined.
| Type | How it combines | Notes |
|---|---|---|
| Hard voting | Majority vote of predicted labels | Simple; ignores confidence |
| Soft voting | Averages predicted probabilities, then picks the top class | Usually better; models must output probabilities |
You can also give each model a weight (stronger models get more say).
from sklearn.ensemble import VotingClassifier, RandomForestClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.neighbors import KNeighborsClassifier
voting = VotingClassifier(
estimators=[
("lr", make_pipeline(StandardScaler(), LogisticRegression(max_iter=1000))),
("rf", RandomForestClassifier(n_estimators=200, random_state=42)),
("knn", make_pipeline(StandardScaler(), KNeighborsClassifier(7))),
],
voting="soft",
weights=[1, 2, 1]
)
voting.fit(X_train, y_train)
print(evaluate(voting))
15.2 Stacking classifier
Stacking trains a second-level model (meta-learner) that learns how best to combine the base models' predictions.
Level 0 (base models): LR ─┐
RF ─┼─► predictions ─► Level 1 (meta-learner) ─► final class
KNN ─┘
Avoiding leakage: the meta-learner is trained on out-of-fold predictions (made via cross-validation), never on predictions the base models made on data they were trained on. scikit-learn handles this with the cv parameter.
from sklearn.ensemble import StackingClassifier
stack = StackingClassifier(
estimators=[
("lr", make_pipeline(StandardScaler(), LogisticRegression(max_iter=1000))),
("rf", RandomForestClassifier(n_estimators=200, random_state=42)),
("knn", make_pipeline(StandardScaler(), KNeighborsClassifier(7))),
],
final_estimator=LogisticRegression(), # keep the meta-learner simple
cv=5,
stack_method="predict_proba",
passthrough=False # True also feeds raw features to the meta-learner
)
stack.fit(X_train, y_train)
print(evaluate(stack))
Voting vs Stacking
| Voting | Stacking | |
|---|---|---|
| Combination rule | Fixed (vote / average) | Learned by a meta-model |
| Complexity | Low | Higher (cross-validation inside) |
| Training cost | Lower | Higher |
| Risk of overfitting | Low | Higher if the meta-learner is complex |
| Potential gain | Moderate | Often larger |
Tips
- Combine different kinds of models; stacking several near-identical models adds little.
- Keep the meta-learner simple (e.g. logistic regression).
- Always compare against your best single model; an ensemble isn't guaranteed to win.
16. Binary Classification
What it is
Binary classification has exactly two classes, usually called positive (1) and negative (0): fraud / not fraud, disease / healthy, churn / stay.
Every algorithm in sections 1–15 works for binary problems; the special topics here are evaluation, thresholds, and imbalance.
The confusion matrix
Predicted
Negative Positive
Actual Negative │ TN FP │ FP = false alarm (Type I error)
Actual Positive │ FN TP │ FN = missed case (Type II error)
confusion_matrix(y_test, logreg.predict(X_test))
Metrics
| Metric | Formula | Answers | Use when |
|---|---|---|---|
| Accuracy | (TP + TN) / total | How often is it right overall? | Classes balanced |
| Precision | TP / (TP + FP) | Of predicted positives, how many are real? | False alarms are costly (spam filter) |
| Recall (sensitivity, TPR) | TP / (TP + FN) | Of real positives, how many did we catch? | Missing a case is costly (disease screening) |
| Specificity (TNR) | TN / (TN + FP) | Of real negatives, how many were correctly cleared? | |
| F1-score | 2·P·R / (P + R) | Balance of precision and recall | Imbalanced data |
| ROC-AUC | Area under TPR vs FPR curve | Ranking quality across all thresholds | Overall comparison; 0.5 = random, 1 = perfect |
| PR-AUC (average precision) | Area under precision-recall curve | Performance on the positive class | Highly imbalanced data |
| MCC (Matthews correlation) | Balanced correlation from all 4 cells | Single score robust to imbalance | Imbalanced data |
| Log loss | −mean[y·ln p + (1−y)·ln(1−p)] | Quality of probabilities | When probabilities matter |
The accuracy trap: if 95% of cases are negative, a model that always predicts "negative" scores 95% accuracy and catches nothing. Look at precision, recall, F1, and PR-AUC.
print(classification_report(y_test, rf.predict(X_test)))
The decision threshold
Models output probabilities; the threshold (default 0.5) turns them into labels. Moving it trades precision against recall.
| Lower the threshold (e.g. 0.3) | Raise the threshold (e.g. 0.7) |
|---|---|
| More positives predicted | Fewer positives predicted |
| Recall ↑, precision ↓ | Precision ↑, recall ↓ |
from sklearn.metrics import precision_recall_curve
proba = rf.predict_proba(X_test)[:, 1]
precision, recall, thresholds = precision_recall_curve(y_test, proba)
# Example: find the threshold with the best F1
f1s = 2 * precision[:-1] * recall[:-1] / (precision[:-1] + recall[:-1] + 1e-12)
best_t = thresholds[f1s.argmax()]
custom_pred = (proba >= best_t).astype(int)
Choose the threshold from business costs (how bad is a false negative compared to a false positive?), and tune it on validation data, not the test set.
Handling class imbalance
| Technique | How |
|---|---|
| Class weights | class_weight="balanced" (logistic regression, trees, forests, SVM, LightGBM), scale_pos_weight (XGBoost), auto_class_weights (CatBoost) |
| Threshold moving | Choose the threshold that optimizes the metric you care about |
| Stratified splitting / CV | Keeps class proportions in every fold |
| Right metrics | F1, PR-AUC, MCC, recall/precision instead of accuracy |
17. Multiclass Classification
What it is
Multiclass classification has three or more mutually exclusive classes, and each sample belongs to exactly one class: digits 0–9, species of flowers, type of animal.
Three ways to handle it
| Strategy | Idea | Examples |
|---|---|---|
| Natively multiclass | The algorithm handles K classes directly | Logistic regression (softmax), KNN, Naive Bayes, trees, forests, LDA/QDA, boosting libraries |
| One-vs-Rest (OvR / OvA) | Train K binary classifiers, each "class k vs everything else"; pick the highest score | LinearSVC by default; wrap any binary model |
| One-vs-One (OvO) | Train a binary classifier for every pair of classes (K(K−1)/2 of them); majority vote | SVC uses this internally |
OvR with 3 classes: [A vs rest] [B vs rest] [C vs rest] → pick the most confident
OvO with 3 classes: [A vs B] [A vs C] [B vs C] → majority vote
Softmax
Native multiclass models usually output a probability for each class using softmax, which makes the probabilities positive and sum to 1.
Example
from sklearn.multiclass import OneVsRestClassifier, OneVsOneClassifier
from sklearn.svm import LinearSVC
# 4-class dataset
X_mc, y_mc = make_classification(n_samples=1500, n_features=12, n_informative=8,
n_classes=4, n_clusters_per_class=1, random_state=42)
Xm_tr, Xm_te, ym_tr, ym_te = train_test_split(X_mc, y_mc, test_size=0.2,
stratify=y_mc, random_state=42)
native = RandomForestClassifier(random_state=42).fit(Xm_tr, ym_tr)
ovr = OneVsRestClassifier(make_pipeline(StandardScaler(), LogisticRegression(max_iter=1000))).fit(Xm_tr, ym_tr)
ovo = OneVsOneClassifier(LinearSVC()).fit(Xm_tr, ym_tr)
for name, m in [("RF", native), ("OvR-LR", ovr), ("OvO-LinearSVC", ovo)]:
print(name, accuracy_score(ym_te, m.predict(Xm_te)))
Evaluating multiclass models
- Confusion matrix (now
K × K): shows which classes get confused with each other. - Precision, recall, and F1 are computed per class, then averaged:
| Averaging | How | Use when |
|---|---|---|
| Macro | Simple average over classes | All classes matter equally (penalizes poor small-class performance) |
| Weighted | Average weighted by class size | You want results reflecting class frequencies |
| Micro | Pool all TP/FP/FN together | Overall performance; equals accuracy for single-label multiclass |
print(classification_report(ym_te, native.predict(Xm_te)))
print("Macro F1:", f1_score(ym_te, native.predict(Xm_te), average="macro"))
print("ROC-AUC (OvR):", roc_auc_score(ym_te, native.predict_proba(Xm_te), multi_class="ovr"))
Practical notes
- Use stratified splits so each class appears in train and test.
- Watch for class imbalance across many classes: macro-averaged metrics expose weak small classes.
- With many classes, OvO's number of models grows quickly (
K(K−1)/2).
18. Multilabel Classification
What it is
In multilabel classification, each sample can have several labels at the same time: a movie that is Action + Comedy, a photo tagged beach, sunset, people, an article tagged politics + economy.
How it differs from multiclass
| Multiclass | Multilabel | |
|---|---|---|
| Labels per sample | Exactly 1 | 0, 1, or many |
| Classes | Mutually exclusive | Not exclusive |
Target y | Vector of class ids | Binary matrix (samples × labels) |
| Output activation | Softmax (probabilities sum to 1) | Independent sigmoids (each label has its own probability) |
Multiclass target: Multilabel target (indicator matrix):
y = [2, 0, 1, 2] Action Comedy Drama
film 1 [ 1 1 0 ]
film 2 [ 0 0 1 ]
film 3 [ 1 0 1 ]
Approaches
| Approach | Idea | Pros | Cons |
|---|---|---|---|
| Binary Relevance | Train one independent binary classifier per label | Simple, scalable | Ignores label correlations |
| Classifier Chains | Train classifiers in sequence; each uses earlier labels' predictions as extra features | Captures label dependencies | Order matters; errors can propagate |
| Label Powerset | Treat each unique combination of labels as one multiclass class | Captures correlations fully | Explodes with many labels; rare combinations |
| Natively multilabel models | Algorithms that accept a 2-D label matrix directly | One model, shared structure | Only some algorithms support it |
In scikit-learn, decision trees, random forests, extra trees and KNN accept a 2-D label matrix directly. Others can be wrapped with OneVsRestClassifier or MultiOutputClassifier (binary relevance) or ClassifierChain.
Example
from sklearn.datasets import make_multilabel_classification
from sklearn.multioutput import MultiOutputClassifier, ClassifierChain
from sklearn.multiclass import OneVsRestClassifier
from sklearn.metrics import hamming_loss, jaccard_score
X_ml, Y_ml = make_multilabel_classification(n_samples=1000, n_features=15,
n_classes=5, n_labels=2, random_state=42)
Xl_tr, Xl_te, Yl_tr, Yl_te = train_test_split(X_ml, Y_ml, test_size=0.2, random_state=42)
# Binary relevance
br = MultiOutputClassifier(LogisticRegression(max_iter=1000)).fit(Xl_tr, Yl_tr)
# Classifier chain
chain = ClassifierChain(LogisticRegression(max_iter=1000), order="random", random_state=0).fit(Xl_tr, Yl_tr)
# Native multilabel
rf_ml = RandomForestClassifier(n_estimators=200, random_state=42).fit(Xl_tr, Yl_tr)
for name, m in [("Binary relevance", br), ("Chain", chain), ("Random forest", rf_ml)]:
pred = m.predict(Xl_te)
print(name,
"| Hamming loss:", round(hamming_loss(Yl_te, pred), 3),
"| Micro F1:", round(f1_score(Yl_te, pred, average="micro"), 3))
Evaluation metrics
| Metric | Meaning |
|---|---|
| Hamming loss | Fraction of individual label assignments that are wrong (lower is better) |
| Subset accuracy (exact match) | Fraction of samples where all labels are exactly right (strict; accuracy_score does this for multilabel) |
| Micro-F1 | Pools all label decisions together; dominated by frequent labels |
| Macro-F1 | Averages F1 per label; treats all labels equally (shows rare-label performance) |
| Samples-F1 | F1 computed per sample, then averaged |
| Jaccard score | Overlap between predicted and true label sets |
Practical notes
- You can set a separate threshold per label (rare labels often need lower thresholds).
- Check label frequency and label co-occurrence during analysis; some labels may be very rare.
- Use iterative stratification-style splitting when possible so rare labels appear in both train and test.
19. Ordinal Classification
What it is
Ordinal classification predicts categories that have a natural order, where the gaps between them aren't necessarily equal:
- Ratings: 1★ < 2★ < 3★ < 4★ < 5★
- Severity: mild < moderate < severe
- Education: high school < bachelor's < master's < PhD
- Survey answers: strongly disagree < … < strongly agree
Why not just use regular multiclass or regression?
| Approach | Problem |
|---|---|
| Plain multiclass | Ignores order: predicting "5★" when the truth is "1★" is treated as the same mistake as predicting "2★" |
| Regression + rounding | Assumes equal spacing between levels, which may be false |
An ordinal method uses the order information while not assuming numeric distances.
Common approaches
A) Ordinal (cumulative link) logistic regression: "proportional odds"
Model the cumulative probabilities P(y ≤ k) with one shared set of coefficients and a separate threshold (cut-point) for each boundary between levels.
logit P(y ≤ k) = θₖ − (β₁x₁ + β₂x₂ + …) for k = 1, …, K−1
- One coefficient set
βfor all levels (the proportional odds assumption). K − 1thresholdsθₖ.
from statsmodels.miscmodels.ordinal_model import OrderedModel
# y_ord: integer codes 0..K-1 ; X_df: DataFrame WITHOUT a constant column
model = OrderedModel(y_ord_train, X_df_train, distr="logit")
result = model.fit(method="bfgs", disp=False)
print(result.summary())
probs = result.predict(X_df_test) # probability for each ordered level
pred = np.asarray(probs).argmax(axis=1)
B) Cumulative binary classifiers (Frank & Hall method)
Turn a K-level problem into K − 1 binary problems: "Is y > k?" Then combine them into class probabilities. Works with any binary classifier.
# Build an example ordinal target: 5 ordered levels from a latent score
from sklearn.datasets import make_regression
Xo, latent = make_regression(n_samples=1500, n_features=8, noise=20, random_state=42)
yo = pd.qcut(latent, q=5, labels=False) # levels 0,1,2,3,4
Xo_tr, Xo_te, yo_tr, yo_te = train_test_split(Xo, yo, test_size=0.2, stratify=yo, random_state=42)
classes = np.sort(np.unique(yo_tr))
models = {k: LogisticRegression(max_iter=1000).fit(Xo_tr, (yo_tr > k).astype(int))
for k in classes[:-1]}
# P(y > k) for each cut-point
p_gt = np.column_stack([models[k].predict_proba(Xo_te)[:, 1] for k in classes[:-1]])
# Convert to P(y = class)
probs = np.zeros((len(Xo_te), len(classes)))
probs[:, 0] = 1 - p_gt[:, 0]
for i in range(1, len(classes) - 1):
probs[:, i] = p_gt[:, i - 1] - p_gt[:, i]
probs[:, -1] = p_gt[:, -1]
probs = np.clip(probs, 0, 1)
pred_ord = classes[probs.argmax(axis=1)]
C) Regression with thresholds
Treat the level as a number, fit any regressor, then round or cut the prediction into levels (cut-points can be tuned on validation data). Quick and often decent, but assumes roughly even spacing.
D) Plain multiclass, evaluated with ordinal metrics
Sometimes a strong multiclass model (e.g. gradient boosting) does fine; just judge it using order-aware metrics.
Metrics that respect order
| Metric | Why it helps |
|---|---|
| MAE on level indices | Average number of levels the prediction is off |
| Quadratic weighted kappa | Agreement score that penalizes far-off predictions much more |
| Off-by-one (within-1) accuracy | Share of predictions within ±1 level |
| Spearman rank correlation | Is the predicted order consistent with the truth? |
| Confusion matrix | Errors should concentrate near the diagonal |
from sklearn.metrics import cohen_kappa_score, mean_absolute_error
from scipy.stats import spearmanr
print("Accuracy :", accuracy_score(yo_te, pred_ord))
print("MAE (levels) :", mean_absolute_error(yo_te, pred_ord))
print("Within ±1 :", np.mean(np.abs(yo_te - pred_ord) <= 1))
print("Quadratic κ :", cohen_kappa_score(yo_te, pred_ord, weights="quadratic"))
print("Spearman :", spearmanr(yo_te, pred_ord).correlation)
Practical notes
- Encode the levels as ordered integers (0, 1, 2, …) and keep the mapping documented.
- scikit-learn has no dedicated ordinal classifier; use
statsmodels'OrderedModel, the cumulative-binary method above, or specialist ordinal libraries. - If the proportional-odds assumption doesn't hold (effects differ by level), cumulative binary classifiers or flexible models are safer.
Model Comparison Cheat Sheet
| Model | Boundary | Needs scaling | Gives probabilities | Interpretability | Handles nonlinearity | Typical strength |
|---|---|---|---|---|---|---|
| Logistic Regression | Linear | Yes | Yes (well-behaved) | ★★★★★ | Only with added features | Baseline, explainable |
| KNN | Flexible, local | Yes | Rough | ★★★ | Yes | Small data, irregular boundaries |
| Naive Bayes | Linear/quadratic-like | No (Gaussian: helpful) | Yes (often overconfident) | ★★★★ | Limited | Text, tiny data, speed |
| Decision Tree | Axis-aligned boxes | No | Coarse | ★★★★★ | Yes | Explainable rules |
| Random Forest | Flexible | No | Yes | ★★ | Yes | Strong default |
| SVM | Linear or kernel-curved | Yes | Via calibration (slow) | ★★ | Yes (kernels) | High dimensions, medium data |
| LDA | Linear | Helpful | Yes | ★★★★ | No | Gaussian-like classes, dimension reduction |
| QDA | Quadratic | Helpful | Yes | ★★★ | Quadratic only | Classes with different spreads |
| AdaBoost | Flexible | No | Yes | ★★ | Yes | Simple boosting on clean data |
| Gradient Boosting | Flexible | No | Yes | ★★ | Yes | High accuracy |
| XGBoost | Flexible | No | Yes | ★★ | Yes | Top tabular accuracy |
| LightGBM | Flexible | No | Yes | ★★ | Yes | Large data, speed |
| CatBoost | Flexible | No | Yes | ★★ | Yes | Categorical data, low tuning |
| Extra Trees | Flexible | No | Yes | ★★ | Yes | Fast, low-variance forests |
| Voting / Stacking | Flexible | Depends on parts | Yes | ★ | Yes | Squeezing out extra accuracy |
Problem types at a glance
| Type | Classes | Labels per sample | Output | Key metric ideas |
|---|---|---|---|---|
| Binary | 2 | 1 | One probability (threshold) | Precision, recall, F1, ROC-AUC, PR-AUC |
| Multiclass | ≥ 3 | Exactly 1 | Softmax over classes | Macro/weighted F1, confusion matrix |
| Multilabel | ≥ 2 | 0 to many | Independent probability per label | Hamming loss, micro/macro F1, subset accuracy |
| Ordinal | ≥ 3, ordered | Exactly 1 | Ordered levels | MAE, quadratic weighted kappa, within-±1 accuracy |
How to choose (quick guide)
Need an explainable, probability-based baseline?
└─► Logistic Regression (add regularization), or a shallow Decision Tree
Text or very high-dimensional sparse data, little data?
└─► Naive Bayes, linear SVM, Logistic Regression
Classes look Gaussian / need supervised dimension reduction?
└─► LDA (shared spread) or QDA (different spread, more data)
Small/medium data with curved boundaries?
└─► SVM (RBF) or KNN (with scaling)
Messy tabular data, want strong accuracy with little tuning?
└─► Random Forest / Extra Trees
Need the best possible accuracy on tabular data?
└─► Gradient Boosting family
• lots of categorical columns → CatBoost
• very large data / speed → LightGBM
• robust all-rounder → XGBoost
Want to squeeze out more?
└─► Voting (simple) or Stacking (learned combination) of diverse models
Problem shape?
├─ two classes → Binary (tune threshold, pick the right metric)
├─ many exclusive classes → Multiclass (softmax / OvR / OvO)
├─ several tags at once → Multilabel (binary relevance, chains, native)
└─ ordered categories → Ordinal (cumulative models, order-aware metrics)
Key Takeaways
- Logistic regression is the interpretable baseline; KNN, Naive Bayes, LDA/QDA and SVM each shine under specific data conditions (scaling and assumptions matter).
- Single trees are explainable but unstable; bagging (Random Forest, Extra Trees) cuts variance; boosting (AdaBoost, Gradient Boosting, XGBoost, LightGBM, CatBoost) reduces bias step by step, so use early stopping.
- LDA vs QDA is a bias-variance choice: shared covariance (linear, stable) vs per-class covariance (curved, data-hungry).
- Voting averages models; stacking learns how to combine them from out-of-fold predictions.
- Never rely on accuracy alone, especially with imbalance: use precision, recall, F1, PR-AUC, and tune the threshold.
- Multiclass = one label from many; multilabel = many labels at once; ordinal = one label from an ordered set, so use order-aware methods and metrics.
- Always stratify splits, keep preprocessing inside a pipeline, and tune with cross-validation.
#Unsupervised Learning#
Unsupervised learning = finding structure in data that has no answer key. There is no target column telling the model what's "right". Instead, the algorithm looks at the data itself and discovers groups, patterns, and relationships. Think of sorting a pile of unlabelled photos into piles of "things that look alike", or noticing that people who buy diapers often buy beer too.
These notes cover two big families of unsupervised methods:
- Clustering: grouping similar data points together (sections 1–13).
- Association rule learning: discovering items that frequently occur together (sections 14–18).
Table of Contents
- Clustering Fundamentals
- K-Means Clustering
- K-Medoids Clustering
- Hierarchical Clustering
- Agglomerative and Divisive Clustering
- DBSCAN
- HDBSCAN
- OPTICS
- Mean Shift Clustering
- Gaussian Mixture Models (GMM)
- Fuzzy C-Means Clustering
- Spectral Clustering
- Clustering Evaluation
- Association Rule Learning
- Apriori Algorithm
- ECLAT Algorithm
- FP-Growth Algorithm
- Market Basket Analysis
- Cheat Sheets and Decision Guides
1. Clustering Fundamentals
What is clustering?
Clustering partitions data into groups (clusters) so that points in the same cluster are similar to each other and points in different clusters are dissimilar.
Before clustering After clustering
· · · · ○ ○ △ △
· · · · ──► ○ ○ △ △
· · · ○ ▢ ▢
· · · · ▢ ▢ ▢ ▢
(no labels) (groups discovered by the algorithm)
Typical uses: customer segmentation, grouping documents or images, finding patterns in genes, summarizing large datasets, spotting unusual data (noise/outliers).
Why it's hard
- There's no ground truth, so "a good clustering" depends on what you want from it.
- The "right" number of clusters is often unknown.
- Clusters can have very different shapes, sizes, and densities.
- Results depend on the distance measure and feature scaling.
Types of clustering (the big map)
| Family | Idea | Algorithms in these notes |
|---|---|---|
| Partitioning (centroid-based) | Split data into k groups around centres | K-Means, K-Medoids, Fuzzy C-Means |
| Hierarchical | Build a tree of nested clusters | Agglomerative, Divisive |
| Density-based | Clusters = dense regions separated by sparse regions | DBSCAN, HDBSCAN, OPTICS, Mean Shift |
| Model-based (distribution-based) | Data came from a mix of probability distributions | Gaussian Mixture Models |
| Graph-based / spectral | Cluster using a similarity graph and its eigenvectors | Spectral Clustering |
Hard vs soft clustering
| Hard clustering | Soft (fuzzy / probabilistic) clustering | |
|---|---|---|
| Assignment | Each point belongs to exactly one cluster | Each point has a degree or probability of belonging to each cluster |
| Examples | K-Means, DBSCAN, Agglomerative | GMM, Fuzzy C-Means |
Distance and similarity measures
Almost every clustering method relies on "how far apart are two points?".
| Measure | Idea | Notes |
|---|---|---|
| Euclidean | Straight-line distance | Default; sensitive to scale |
| Manhattan (L1) | Sum of absolute differences | Less sensitive to outliers; grid-like paths |
| Minkowski | Generalizes both (p parameter) | |
| Cosine | Angle between vectors | Text/high-dimensional data |
| Hamming / Jaccard | Mismatches / overlap of sets | Binary or categorical data |
Data preparation matters
- Scale your features (standardize). A feature measured in thousands will otherwise drown out one measured in decimals.
- Handle outliers: they can distort centroid-based methods.
- Mind high dimensions: in many dimensions, distances between points become similar (curse of dimensionality), so clustering degrades.
- Choose features that actually describe similarity for your goal.
Setup used in the code examples
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.datasets import make_blobs, make_moons
from sklearn.preprocessing import StandardScaler
# Round-ish blobs (good for K-Means / GMM)
X_blobs, y_blobs = make_blobs(n_samples=500, centers=4, cluster_std=0.9, random_state=42)
X_blobs = StandardScaler().fit_transform(X_blobs)
# Two interleaving half-moons (hard for K-Means, good for density / spectral methods)
X_moons, y_moons = make_moons(n_samples=500, noise=0.07, random_state=42)
# Clusters with very different densities
X_var, y_var = make_blobs(n_samples=[200, 300, 300],
centers=[[0, 0], [6, 6], [12, 0]],
cluster_std=[0.3, 1.0, 1.5], random_state=42)
2. K-Means Clustering
Idea
K-Means splits data into k clusters, each represented by its centroid (the mean of its points). Every point belongs to the cluster with the nearest centroid.
The objective
It minimizes the within-cluster sum of squares (WCSS), also called inertia:
Inertia = Σ over clusters Σ over points in cluster ‖ x − centroid ‖²
The algorithm (Lloyd's algorithm)
1. Choose k and place k initial centroids.
2. ASSIGN: give each point to its nearest centroid.
3. UPDATE: move each centroid to the mean of its assigned points.
4. Repeat steps 2–3 until assignments stop changing (or a max number of iterations).
Iteration 1 Iteration 2 Converged
× ○ ○ × ○ ○ × ○ ○
○ ○ ● ● ○ ○ ● ● ○ ○ ● ●
○ × ● ○ × × ... × ×
(random centres) (centres moving) (stable centres)
Initialization: k-means++
Random starts can lead to poor results (K-Means finds a local minimum, not necessarily the best). k-means++ spreads out the initial centroids (choosing far-away points with higher probability) and is the default in scikit-learn. Running multiple starts (n_init) and keeping the best result adds more safety.
Choosing k
| Method | How |
|---|---|
| Elbow method | Plot inertia vs k; look for the "elbow" where improvement slows |
| Silhouette score | Pick the k with the highest average silhouette |
| Domain knowledge | Sometimes the business decides (e.g. 3 customer tiers) |
| Gap statistic | Compares inertia to that of random data |
Example
from sklearn.cluster import KMeans
from sklearn.metrics import silhouette_score
inertias, sils = [], []
ks = range(2, 11)
for k in ks:
km = KMeans(n_clusters=k, init="k-means++", n_init=10, random_state=42).fit(X_blobs)
inertias.append(km.inertia_)
sils.append(silhouette_score(X_blobs, km.labels_))
fig, ax = plt.subplots(1, 2, figsize=(11, 4))
ax[0].plot(ks, inertias, "o-"); ax[0].set(title="Elbow method", xlabel="k", ylabel="Inertia")
ax[1].plot(ks, sils, "o-"); ax[1].set(title="Silhouette score", xlabel="k")
plt.show()
km = KMeans(n_clusters=4, n_init=10, random_state=42).fit(X_blobs)
labels, centers = km.labels_, km.cluster_centers_
Characteristics
| Strengths | Limits |
|---|---|
| Simple, fast, scales to large data | Must specify k in advance |
| Easy to interpret (centroids) | Assumes round (spherical), similar-sized clusters |
| Works well on clean, well-separated blobs | Sensitive to outliers (they pull means) |
MiniBatchKMeans handles huge datasets | Can get stuck in local optima; needs scaling; numeric features only |
Cannot find non-convex shapes like the two moons. Use density or spectral methods for those.
3. K-Medoids Clustering
Idea
K-Medoids is like K-Means, but each cluster is represented by a medoid, which is an actual data point (the most central one), instead of a mean that may not correspond to any real observation.
| K-Means | K-Medoids | |
|---|---|---|
| Cluster centre | Mean (may be a non-existent point) | Medoid (a real data point) |
| Minimizes | Sum of squared distances | Sum of (usually unsquared) distances / dissimilarities |
| Outlier sensitivity | High | Low (medoid isn't pulled by extremes) |
| Distance measure | Basically Euclidean | Any dissimilarity (Manhattan, Gower, custom...) |
| Cost | Cheap | More expensive |
Algorithm: PAM (Partitioning Around Medoids)
BUILD: pick k starting medoids (greedy selection).
SWAP: for each (medoid, non-medoid) pair, try swapping them;
keep the swap if total distance decreases.
Repeat SWAP until no swap improves the cost.
PAM costs roughly O(k·(n−k)²) per iteration, so it gets slow on big datasets. CLARA runs PAM on random samples to scale up, and CLARANS is a randomized search variant.
Example
# pip install scikit-learn-extra (it may lag behind the newest scikit-learn releases)
from sklearn_extra.cluster import KMedoids
kmed = KMedoids(n_clusters=4, metric="manhattan", method="pam", random_state=42).fit(X_blobs)
print("Medoid points:\n", kmed.cluster_centers_) # these are real rows from the data
labels = kmed.labels_
When to choose it
- Data contains outliers or noise.
- You want cluster representatives that are real examples (e.g. "the most typical customer").
- Your distance isn't Euclidean (mixed or categorical data with a suitable dissimilarity).
4. Hierarchical Clustering
Idea
Instead of choosing k up front, hierarchical clustering builds a tree of nested clusters, showing how groups merge (or split) at different levels of similarity. You then cut the tree at a chosen level to get your clusters.
The dendrogram
A dendrogram is the tree diagram of the process.
Distance
▲
│ ┌──────────────┐
│ ┌───┴───┐ │
│ ┌─┴─┐ │ ┌──┴──┐
│ A B C D E
└─────────────────────────────────►
(leaves = individual points)
- Leaves: individual data points.
- Height of a merge: the distance (dissimilarity) at which two clusters were joined.
- Cutting horizontally at a height gives clusters: a high cut → few big clusters, a low cut → many small ones.
- A big vertical gap with no merges suggests a natural number of clusters.
Linkage: how to measure the distance between two clusters
| Linkage | Distance between clusters = | Behaviour |
|---|---|---|
| Single | Closest pair of points | Can chain points into long, stringy clusters; sensitive to noise |
| Complete | Farthest pair of points | Compact clusters of similar diameter; sensitive to outliers |
| Average | Average of all pairwise distances | A balanced compromise |
| Ward | Merge that gives the smallest increase in total within-cluster variance | Compact, similar-sized clusters; Euclidean only; very popular default |
| Centroid | Distance between cluster means | Can produce inversions in the dendrogram |
Example (SciPy)
from scipy.cluster.hierarchy import linkage, dendrogram, fcluster, cophenet
from scipy.spatial.distance import pdist
Z = linkage(X_blobs, method="ward") # the full merge tree
plt.figure(figsize=(10, 4))
dendrogram(Z, truncate_mode="lastp", p=20, show_leaf_counts=True)
plt.title("Dendrogram (last 20 merges)"); plt.show()
labels = fcluster(Z, t=4, criterion="maxclust") # cut into 4 clusters
# or cut at a distance: fcluster(Z, t=7.5, criterion="distance")
# How faithfully does the tree preserve the original distances? (closer to 1 is better)
c, _ = cophenet(Z, pdist(X_blobs))
print("Cophenetic correlation:", round(c, 3))
Characteristics
| Strengths | Limits |
|---|---|
No need to fix k in advance | Slow on large datasets (≥ O(n²) time and memory) |
| Dendrogram shows structure at every scale | Merges are irreversible (greedy) |
| Works with various linkages / distance measures | Sensitive to noise/outliers (depends on linkage) |
| Deterministic (no random starts) | Hard to read dendrograms with thousands of points |
5. Agglomerative and Divisive Clustering
These are the two directions of building a hierarchy.
AGGLOMERATIVE (bottom-up) DIVISIVE (top-down)
start: every point alone start: all points in one cluster
○ ○ ○ ○ ○ ○ ┌─────────────┐
└┬┘ └┬┘ └┬┘ merge closest │ ○ ○ ○ ○ ○ ○ │
└──┬─┘ │ pairs repeatedly └──────┬──────┘ split repeatedly
└─────┘ ▼
end: everything in one cluster end: every point alone
| Agglomerative (AGNES) | Divisive (DIANA) | |
|---|---|---|
| Direction | Bottom-up | Top-down |
| Start | n singleton clusters | One cluster with everything |
| Step | Merge the two closest clusters | Split a cluster into two |
| Decision basis | Local (pairs of clusters) | Global (sees all data when splitting) |
| Cost | About O(n²) memory; O(n²) to O(n³) time | Exhaustive splitting is exponential, so practical versions use heuristics |
| Popularity | Much more common | Rarer |
Agglomerative algorithm
1. Compute the distance matrix between all points.
2. Treat each point as its own cluster.
3. Merge the two closest clusters (using the chosen linkage).
4. Update the distances between the new cluster and all others.
5. Repeat until one cluster remains (or the target number is reached).
from sklearn.cluster import AgglomerativeClustering
agg = AgglomerativeClustering(n_clusters=4, linkage="ward").fit(X_blobs)
labels = agg.labels_
# Alternatively, let a distance threshold decide the number of clusters
agg_t = AgglomerativeClustering(n_clusters=None, distance_threshold=7.5,
linkage="average").fit(X_blobs)
print("Clusters found:", agg_t.n_clusters_)
# Connectivity constraints (only allow merging neighbours) can also be supplied
# via the `connectivity` parameter, e.g. a k-nearest-neighbour graph.
Divisive algorithm
DIANA (Divisive Analysis) starts with everything in one cluster, picks the cluster with the largest diameter, finds its most dissimilar point to start a "splinter group", and moves over points that are closer to the splinter group than to the rest, then repeats.
A practical divisive method available in scikit-learn is Bisecting K-Means: repeatedly split one cluster in two using K-Means.
from sklearn.cluster import BisectingKMeans
bkm = BisectingKMeans(n_clusters=4, bisecting_strategy="biggest_inertia", random_state=42).fit(X_blobs)
labels = bkm.labels_
Which to use?
- Agglomerative: the standard choice and well supported in libraries.
- Divisive: can be better when you only need the top few levels of the hierarchy (fewer splits are needed than merges) and can find big structures more reliably, since it uses global information.
6. DBSCAN
Idea
DBSCAN (Density-Based Spatial Clustering of Applications with Noise) defines clusters as dense regions of points separated by regions of low density. It needs no k, finds clusters of any shape, and labels sparse points as noise.
Two parameters
| Parameter | Meaning |
|---|---|
eps (ε) | Radius of the neighbourhood around a point |
min_samples | Minimum number of points (including the point itself) within eps to count as "dense" |
Three kinds of points
| Type | Definition |
|---|---|
| Core point | Has at least min_samples points within eps |
| Border point | Not a core point, but lies within eps of a core point |
| Noise point | Neither: an outlier (label −1) |
● ← core (many neighbours inside the circle)
╱ ╲
◐ ◐ ← border (near a core, but few neighbours of its own)
× ← noise (isolated)
The algorithm
1. Pick an unvisited point; find all points within eps.
2. If it has ≥ min_samples neighbours, start a new cluster (it's a core point).
3. Expand: add all density-reachable points (neighbours of core points, recursively).
4. If it's not a core point and not reachable from one, mark it as noise.
5. Repeat until every point is visited.
Choosing the parameters
min_samples: a common rule of thumb is≥ dimensions + 1, often2 × dimensions. Larger values give more noise resistance (default 5 in scikit-learn).eps: use a k-distance plot. For each point, compute the distance to itsmin_samples-th nearest neighbour, sort, and plot. The "elbow" is a goodeps.
from sklearn.cluster import DBSCAN
from sklearn.neighbors import NearestNeighbors
min_samples = 5
nn = NearestNeighbors(n_neighbors=min_samples).fit(X_moons)
dist, _ = nn.kneighbors(X_moons)
k_dist = np.sort(dist[:, -1])
plt.plot(k_dist); plt.ylabel(f"Distance to {min_samples}th neighbour")
plt.title("k-distance plot (look for the elbow)"); plt.show()
db = DBSCAN(eps=0.15, min_samples=min_samples).fit(X_moons)
labels = db.labels_
print("Clusters:", len(set(labels)) - (1 if -1 in labels else 0))
print("Noise points:", (labels == -1).sum())
Characteristics
| Strengths | Limits |
|---|---|
| Finds arbitrary-shaped clusters (moons, rings) | Struggles when clusters have very different densities (one eps can't suit all) |
| Identifies noise/outliers automatically | eps is sensitive and hard to pick |
No need to specify k | Distances lose meaning in high dimensions |
| Only two parameters | Border points can be assigned order-dependently; needs scaling |
7. HDBSCAN
Idea
HDBSCAN (Hierarchical DBSCAN) fixes DBSCAN's biggest weakness, the single global eps. It runs DBSCAN across all possible eps values, builds a hierarchy of clusters, and then automatically selects the most stable clusters. The result handles clusters of varying density.
How it works (simplified)
1. Mutual reachability distance: a "robust" distance that pushes sparse points
farther apart (uses each point's core distance, i.e. distance to its min_samples-th neighbour).
2. Build a minimum spanning tree over those distances.
3. Convert it into a cluster hierarchy (like a dendrogram).
4. Condense the tree: discard splits that only shed tiny groups (< min_cluster_size).
5. Select clusters that persist over the widest range of density levels (highest "stability").
Key parameters
| Parameter | Meaning |
|---|---|
min_cluster_size | Smallest group you'd call a cluster (the main knob; intuitive) |
min_samples | How conservative the density estimate is; higher → more points labelled noise |
cluster_selection_method | "eom" (excess of mass, default, finds large stable clusters) or "leaf" (finer, smaller clusters) |
cluster_selection_epsilon | Merge clusters closer than this distance |
Example
from sklearn.cluster import HDBSCAN # available in scikit-learn ≥ 1.3
# (the standalone `hdbscan` package offers extra tools, such as plotting the condensed tree)
hdb = HDBSCAN(min_cluster_size=15, min_samples=5).fit(X_var)
labels = hdb.labels_ # -1 = noise
probs = hdb.probabilities_ # strength of membership (0 to 1)
print("Clusters:", len(set(labels)) - (1 if -1 in labels else 0))
DBSCAN vs HDBSCAN
| DBSCAN | HDBSCAN | |
|---|---|---|
| Density levels | One global eps | Varying density supported |
| Main parameters | eps, min_samples | min_cluster_size (+ optional min_samples) |
| Parameter sensitivity | High | Lower |
| Output | Labels (−1 noise) | Labels (−1 noise) + membership probabilities |
| Speed | Faster | Slower |
8. OPTICS
Idea
OPTICS (Ordering Points To Identify the Clustering Structure) also tackles DBSCAN's varying-density problem. It doesn't output clusters directly; instead it produces an ordering of the points plus a "reachability distance" for each. Plotting that gives a reachability plot, from which clusters at many density levels can be read off.
Key concepts
| Term | Meaning |
|---|---|
| Core distance | Smallest radius around a point that contains min_samples points |
| Reachability distance | How far a point is from a core point, at least as large as that core's core distance |
| Ordering | A processing order where each point is followed by its closest density-reachable neighbours |
Reading the reachability plot
Reachability
distance
▲ ╷ ╷
│ ╷ ╷
│ ╷ ╷ ╷╷ ╷ ← peaks = gaps between clusters
│ ██ ... ╷ ████ ... ██ ... ╷
│ ██████ ╷ ██████████████ ╷
└──────────────────────────────────► points in OPTICS order
cluster A cluster B cluster C
(valleys = dense clusters; deeper valley = denser)
Valleys = clusters, peaks = boundaries. Dense clusters form deep valleys; sparse ones form shallow valleys, so one plot shows clusters of different densities.
Extracting clusters
ximethod: finds valleys by a minimum relative steepnessxi(automatic, handles varying density).dbscanmethod: cuts the plot at a choseneps, giving DBSCAN-like results (a single density level).
Example
from sklearn.cluster import OPTICS
opt = OPTICS(min_samples=10, xi=0.05, min_cluster_size=0.05).fit(X_var)
reach = opt.reachability_[opt.ordering_] # the reachability plot values
plt.plot(reach); plt.title("Reachability plot"); plt.show()
labels = opt.labels_ # -1 = noise
Characteristics
| Strengths | Limits |
|---|---|
| Handles varying density | Slower than DBSCAN and uses more memory |
| Reachability plot visualizes the cluster structure | The plot can be tricky to interpret |
Little need to tune eps | Parameters (xi, min_samples) still matter |
| Finds noise | Output differs slightly from DBSCAN for the same settings |
9. Mean Shift Clustering
Idea
Mean Shift treats the data as samples from a smooth density landscape and finds its peaks (modes). Every point "climbs the hill" toward the nearest peak, and points that end at the same peak form a cluster.
How it works
For every seed point:
1. Draw a window (circle) of radius = bandwidth around it.
2. Compute the MEAN of all points inside the window.
3. SHIFT the window centre to that mean (it moves toward denser regions).
4. Repeat until the shift becomes negligible → the window sits on a density peak (mode).
Merge peaks that are close together; each remaining peak is a cluster centre.
○ ○ ○ ○ window shifts uphill
○ ○ ○ ○ ○ → ⊙ → ⊙ → ⊙ (peak)
○ ○ ○ ○
The one important parameter: bandwidth
| Bandwidth | Effect |
|---|---|
| Small | Many small clusters (lots of peaks) |
| Large | Few big clusters (peaks merge) |
scikit-learn's estimate_bandwidth gives a data-driven starting point.
Example
from sklearn.cluster import MeanShift, estimate_bandwidth
bw = estimate_bandwidth(X_blobs, quantile=0.2, n_samples=500)
ms = MeanShift(bandwidth=bw, bin_seeding=True).fit(X_blobs)
print("Bandwidth:", round(bw, 3))
print("Clusters found:", len(ms.cluster_centers_))
labels = ms.labels_
Characteristics
| Strengths | Limits |
|---|---|
No need to specify k; finds it automatically | Bandwidth is critical and hard to choose |
| Finds arbitrary-shaped, mode-based clusters | Slow on large datasets (roughly quadratic) |
| Robust to outliers (they don't form modes) | Poor in high dimensions |
| Few parameters | Uses a flat kernel in scikit-learn; assumes one bandwidth for all clusters |
10. Gaussian Mixture Models (GMM)
Idea
A GMM assumes the data was generated by a mixture of several Gaussian (normal) distributions, each with its own mean, covariance, and weight. Clustering then means working out which Gaussian most likely produced each point. It is soft clustering: each point gets a probability of belonging to every component.
p(x) = Σₖ πₖ · N(x | μₖ, Σₖ)
πₖ = mixture weight (share of data from component k)
μₖ = mean Σₖ = covariance (shape and orientation of the ellipse)
The EM algorithm (Expectation–Maximization)
Initialize means, covariances, weights (often from K-Means).
Repeat until the likelihood stops improving:
E-step: for every point, compute the RESPONSIBILITY, the probability that
each component generated it (soft assignment).
M-step: re-estimate each component's weight, mean and covariance using the
responsibilities as weights.
Covariance types (cluster shapes)
covariance_type | Shape allowed | Parameters |
|---|---|---|
spherical | Circles, different radii | Fewest |
diag | Axis-aligned ellipses | Few |
tied | Ellipses, same shape for all components | Medium |
full | Arbitrary rotated ellipses, each its own | Most (most flexible) |
Choosing the number of components
Use information criteria; lower is better:
- BIC (Bayesian Information Criterion): penalizes complexity strongly (usually preferred).
- AIC (Akaike Information Criterion): a lighter penalty.
Example
from sklearn.mixture import GaussianMixture
bics = []
for k in range(1, 9):
g = GaussianMixture(n_components=k, covariance_type="full", n_init=5, random_state=42).fit(X_blobs)
bics.append(g.bic(X_blobs))
best_k = int(np.argmin(bics)) + 1
print("Best k by BIC:", best_k)
gmm = GaussianMixture(n_components=best_k, covariance_type="full", n_init=5, random_state=42).fit(X_blobs)
hard_labels = gmm.predict(X_blobs)
soft_probs = gmm.predict_proba(X_blobs) # each row sums to 1
print(soft_probs[:3].round(3))
new_points, _ = gmm.sample(10) # GMM is also a generative model
K-Means vs GMM
| K-Means | GMM | |
|---|---|---|
| Assignment | Hard | Soft (probabilities) |
| Cluster shape | Spherical | Elliptical (via covariances) |
| Cluster size | Roughly equal | Different sizes (via weights) |
| Model | Centroids only | Full probabilistic model |
| Choosing k | Elbow / silhouette | BIC / AIC |
| Speed | Faster | Slower |
K-Means can be seen as a special case of GMM: equal spherical clusters with hard assignments.
Characteristics
| Strengths | Limits |
|---|---|
| Soft, probabilistic output | Assumes data is (roughly) Gaussian |
| Flexible cluster shapes and sizes | Needs k; can converge to local optima (use n_init) |
| Principled model selection (BIC) | Many parameters in high dimensions with full covariance |
| Can generate new samples | Covariances can become singular (fixed with reg_covar) |
11. Fuzzy C-Means Clustering
Idea
Fuzzy C-Means (FCM) is the "soft" version of K-Means. Every point has a membership degree between 0 and 1 to every cluster, and each point's memberships add up to 1. Points near the border between clusters get split memberships (e.g. 0.55 to A, 0.45 to B), which models real-world overlap.
Hard (K-Means): point → [0, 1, 0]
Fuzzy (FCM): point → [0.10, 0.65, 0.25]
Objective
J = Σᵢ Σⱼ uᵢⱼ^m · ‖ xᵢ − vⱼ ‖²
uᵢⱼ = membership of point i in cluster j
vⱼ = centre of cluster j
m = fuzzifier (m > 1)
The fuzzifier m
m | Effect |
|---|---|
| Close to 1 | Almost hard (like K-Means) |
| 2 (typical default) | Moderate fuzziness |
| Large | Memberships become nearly equal (everything blurry) |
Algorithm (alternating updates)
1. Choose c (number of clusters) and m; initialize memberships U randomly (rows sum to 1).
2. Update centres: vⱼ = Σᵢ uᵢⱼ^m · xᵢ / Σᵢ uᵢⱼ^m
3. Update memberships: uᵢⱼ = 1 / Σₖ ( dᵢⱼ / dᵢₖ )^(2/(m−1)) (d = distance to centre)
4. Repeat 2–3 until the memberships stop changing.
Example (a compact NumPy implementation)
def fuzzy_c_means(X, c=3, m=2.0, max_iter=150, tol=1e-5, seed=42):
rng = np.random.default_rng(seed)
n = X.shape[0]
U = rng.random((n, c))
U /= U.sum(axis=1, keepdims=True) # rows sum to 1
for _ in range(max_iter):
Um = U ** m
centers = (Um.T @ X) / Um.sum(axis=0)[:, None] # update centres
dist = np.linalg.norm(X[:, None, :] - centers[None, :, :], axis=2)
dist = np.fmax(dist, 1e-10) # avoid division by zero
inv = dist ** (-2 / (m - 1))
U_new = inv / inv.sum(axis=1, keepdims=True) # update memberships
if np.linalg.norm(U_new - U) < tol:
U = U_new
break
U = U_new
return centers, U
centers, U = fuzzy_c_means(X_blobs, c=4, m=2.0)
hard_labels = U.argmax(axis=1) # defuzzify if you need hard labels
fpc = np.mean(np.sum(U ** 2, axis=1)) # fuzzy partition coefficient
print("FPC:", round(fpc, 3))
(The scikit-fuzzy package also provides skfuzzy.cluster.cmeans.)
Fuzzy Partition Coefficient (FPC)
FPC = mean of Σⱼ uᵢⱼ², ranging from 1/c (completely fuzzy) to 1 (crisp). Higher means cleaner, more distinct clusters; it can help compare different c.
Characteristics
| Strengths | Limits |
|---|---|
| Captures overlapping clusters and uncertainty | Must choose c and m |
| Smooth memberships useful for borderline cases | Sensitive to initialization and outliers (like K-Means) |
| Simple extension of K-Means | Assumes roughly spherical clusters; memberships aren't true probabilities |
12. Spectral Clustering
Idea
Spectral clustering treats the data as a graph and clusters it by looking at the eigenvectors of a matrix derived from that graph. It excels when clusters are defined by connectivity (points linked in chains or rings) rather than by being compact blobs.
The pipeline
1. Build a SIMILARITY GRAPH: points are nodes; edge weights = similarity.
• RBF (Gaussian) affinity: exp(−γ‖xᵢ − xⱼ‖²)
• or k-nearest-neighbour graph (connect each point to its closest neighbours)
2. Compute the graph LAPLACIAN matrix (L = D − W, or a normalized version).
3. Find the eigenvectors for the k SMALLEST eigenvalues (the "spectral embedding").
4. Run K-Means on the rows of those eigenvectors → final clusters.
Intuition: the eigenvectors re-describe each point so that points in the same connected group sit close together, even if the original shape was a crescent or ring. Then simple K-Means can separate them.
Choosing k: the eigengap heuristic
Look at the sorted eigenvalues of the Laplacian; a large jump (gap) after the first k eigenvalues suggests k clusters.
Key parameters
| Parameter | Meaning |
|---|---|
n_clusters | Number of clusters |
affinity | "rbf" or "nearest_neighbors" (the latter is often better for manifolds like moons) |
gamma | RBF width |
n_neighbors | For the nearest-neighbour graph |
assign_labels | "kmeans" (default), "discretize", "cluster_qr" |
Example
from sklearn.cluster import SpectralClustering
sc = SpectralClustering(n_clusters=2, affinity="nearest_neighbors",
n_neighbors=10, assign_labels="kmeans", random_state=42)
labels = sc.fit_predict(X_moons) # separates the two moons correctly
Characteristics
| Strengths | Limits |
|---|---|
| Handles non-convex shapes | Expensive on large datasets (eigen-decomposition) |
| Based on connectivity, not just distance to centres | Must specify k |
| Works with any similarity measure | Sensitive to the graph construction (gamma, n_neighbors) |
| Solid mathematical foundation | Not great when cluster sizes differ hugely |
13. Clustering Evaluation
With no labels, how do you know a clustering is any good? There are three flavours of evaluation.
13.1 Internal validation (no labels needed)
Judge clusters by compactness (tight) and separation (far from each other).
| Metric | What it measures | Best value |
|---|---|---|
| Inertia (WCSS) | Total squared distance to centroids (used for the elbow) | Lower (but always drops as k grows, so look for the elbow) |
| Silhouette score | For each point: (b − a) / max(a, b), where a = mean distance to its own cluster, b = mean distance to the nearest other cluster | Higher (range −1 to +1) |
| Davies–Bouldin index | Average similarity of each cluster to its most similar one | Lower (0 is best) |
| Calinski–Harabasz index (variance ratio) | Between-cluster dispersion ÷ within-cluster dispersion | Higher |
Reading the silhouette:
| Value | Meaning |
|---|---|
| Near +1 | Point is well inside its cluster |
| Near 0 | Point is on the border between clusters |
| Negative | Point may be in the wrong cluster |
from sklearn.metrics import silhouette_score, silhouette_samples, davies_bouldin_score, calinski_harabasz_score
labels = KMeans(n_clusters=4, n_init=10, random_state=42).fit_predict(X_blobs)
print("Silhouette :", silhouette_score(X_blobs, labels))
print("Davies-Bouldin :", davies_bouldin_score(X_blobs, labels))
print("Calinski-Harabasz:", calinski_harabasz_score(X_blobs, labels))
per_point = silhouette_samples(X_blobs, labels) # for a silhouette plot, one value per point
Caution: these metrics favour compact, round clusters. They can wrongly rate a correct density-based clustering of crescent shapes as "bad". For DBSCAN-style output, evaluate on non-noise points and don't over-trust them. For GMM, BIC/AIC are natural internal criteria.
13.2 External validation (true labels are available)
If you have ground-truth labels (e.g. for testing an algorithm on a benchmark), compare them to the clustering. These metrics ignore the actual label names/numbers.
| Metric | Meaning | Range |
|---|---|---|
| Adjusted Rand Index (ARI) | Agreement on pairs of points, corrected for chance | −1 to 1 (1 = perfect, ~0 = random) |
| Normalized Mutual Information (NMI) | Shared information between clusters and classes | 0 to 1 |
| Homogeneity | Each cluster contains members of only one class | 0 to 1 |
| Completeness | All members of a class are in the same cluster | 0 to 1 |
| V-measure | Harmonic mean of homogeneity and completeness | 0 to 1 |
| Fowlkes–Mallows index | Geometric mean of pairwise precision and recall | 0 to 1 |
from sklearn.metrics import (adjusted_rand_score, normalized_mutual_info_score,
homogeneity_completeness_v_measure, fowlkes_mallows_score)
print("ARI :", adjusted_rand_score(y_blobs, labels))
print("NMI :", normalized_mutual_info_score(y_blobs, labels))
print("Homogeneity / Completeness / V:", homogeneity_completeness_v_measure(y_blobs, labels))
print("FMI :", fowlkes_mallows_score(y_blobs, labels))
13.3 Other practical checks
| Check | How |
|---|---|
| Stability | Re-run on resampled data or with different seeds; do you get similar clusters? (measure with ARI between runs) |
| Visual inspection | Plot clusters (2-D scatter, or pair plots / projections) and silhouette plots |
| Cluster profiling | Compare feature means across clusters. Do they make sense and are they actionable? |
| Size sanity | Beware tiny clusters, or one giant cluster swallowing everything |
| Domain validation | Do experts agree the groups are meaningful? |
Quick reference: choosing the number of clusters
| Algorithm | Tools |
|---|---|
| K-Means / K-Medoids / Fuzzy C-Means | Elbow, silhouette, gap statistic (FPC for fuzzy) |
| Hierarchical | Dendrogram gaps, cophenetic correlation |
| GMM | BIC / AIC |
| Spectral | Eigengap |
| DBSCAN / HDBSCAN / OPTICS / Mean Shift | Number of clusters comes out of the parameters (eps, min_cluster_size, bandwidth, ...) |
14. Association Rule Learning
What is it?
Association rule learning discovers "if–then" relationships between items that frequently occur together in a large set of transactions. It is unsupervised: nobody tells the algorithm which items to relate.
Classic example: "Customers who buy diapers often also buy beer." →
{diapers} ⇒ {beer}
Beyond shopping: web page navigation, symptom–diagnosis patterns, recommendation systems, finding co-occurring items in logs.
Vocabulary
| Term | Meaning |
|---|---|
| Item | A single element (e.g. milk) |
| Itemset | A set of items (e.g. {bread, milk}) |
| Transaction | One record containing a set of items (one shopping basket) |
| k-itemset | An itemset with k items |
Rule A ⇒ B | If A (antecedent) occurs, B (consequent) tends to occur; A and B are disjoint itemsets |
The key measures
Let N = total transactions.
| Measure | Formula | Meaning |
|---|---|---|
| Support | count(A ∪ B) / N | How frequently the itemset appears (popularity) |
| Confidence | support(A ∪ B) / support(A) | Given A, how often is B also present? (`P(B |
| Lift | confidence / support(B) | How much more likely B is given A than by chance |
| Leverage | support(A ∪ B) − support(A)·support(B) | Difference between observed and expected co-occurrence (0 = independent) |
| Conviction | (1 − support(B)) / (1 − confidence) | How much the rule would be wrong if A and B were independent (higher = stronger; ∞ for perfect rules) |
Reading lift:
| Lift | Interpretation |
|---|---|
| > 1 | Positive association: A makes B more likely |
| = 1 | Independent: no relationship |
| < 1 | Negative association: A makes B less likely |
Why confidence alone misleads: if 90% of baskets contain bread, then almost any rule
X ⇒ breadhas high confidence, even when X has no real connection to bread. Lift corrects for this.
Worked mini-example
Five transactions:
| ID | Items |
|---|---|
| T1 | bread, milk |
| T2 | bread, diapers, beer, eggs |
| T3 | milk, diapers, beer, cola |
| T4 | bread, milk, diapers, beer |
| T5 | bread, milk, diapers, cola |
Rule {diapers} ⇒ {beer}:
support(diapers) = 4/5 = 0.80 (T2, T3, T4, T5)
support(beer) = 3/5 = 0.60 (T2, T3, T4)
support(diapers, beer) = 3/5 = 0.60 (T2, T3, T4)
confidence = 0.60 / 0.80 = 0.75
lift = 0.75 / 0.60 = 1.25 → positive association
leverage = 0.60 − 0.80 × 0.60 = 0.12
conviction = (1 − 0.60)/(1 − 0.75) = 1.60
The two-step process
Almost all algorithms (Apriori, ECLAT, FP-Growth) share this structure:
STEP 1: Find all FREQUENT ITEMSETS → itemsets with support ≥ min_support
(the expensive part: the number of possible itemsets is 2^items)
STEP 2: Generate RULES from each frequent itemset
→ keep rules with confidence ≥ min_confidence (and good lift)
The Apriori (downward-closure) property
If an itemset is frequent, then all of its subsets are frequent. Equivalently, if an itemset is infrequent, all its supersets are infrequent too.
This is what lets algorithms prune huge parts of the search space: if {beer, cola} is infrequent, there's no need to look at {beer, cola, bread}.
How the three algorithms differ (preview)
| Apriori | ECLAT | FP-Growth | |
|---|---|---|---|
| Data layout | Horizontal (transaction → items) | Vertical (item → set of transaction ids) | Compact FP-tree |
| Search | Breadth-first, level by level | Depth-first | Divide-and-conquer on the tree |
| Candidate generation | Yes (many) | Yes (via intersections) | No |
| Database scans | Many (one per level) | Few | Two |
15. Apriori Algorithm
Idea
Apriori finds frequent itemsets level by level (breadth-first): first frequent 1-itemsets, then use them to build candidate 2-itemsets, count and filter, then 3-itemsets, and so on, using the downward-closure property to prune.
The algorithm
L1 = frequent single items (support ≥ min_support)
for k = 2, 3, ... until no frequent itemsets remain:
1. JOIN : combine frequent (k−1)-itemsets to form candidate k-itemsets.
2. PRUNE: discard any candidate that has an infrequent (k−1)-subset.
3. COUNT: scan the database to count each remaining candidate's support.
4. FILTER: keep candidates with support ≥ min_support → Lk
Walkthrough (same 5 transactions, min_support = 0.4, i.e. at least 2 transactions)
L1 (single items):
| Item | Count | Frequent? |
|---|---|---|
| bread | 4 | ✓ |
| milk | 4 | ✓ |
| diapers | 4 | ✓ |
| beer | 3 | ✓ |
| cola | 2 | ✓ |
| eggs | 1 | ✗ (pruned) |
L2 (pairs formed from L1):
| Pair | Count | Frequent? |
|---|---|---|
| bread, milk | 3 | ✓ |
| bread, diapers | 3 | ✓ |
| bread, beer | 2 | ✓ |
| bread, cola | 1 | ✗ |
| milk, diapers | 3 | ✓ |
| milk, beer | 2 | ✓ |
| milk, cola | 2 | ✓ |
| diapers, beer | 3 | ✓ |
| diapers, cola | 2 | ✓ |
| beer, cola | 1 | ✗ |
L3 (triples whose every pair is frequent): {bread, milk, diapers}, {bread, diapers, beer}, {milk, diapers, beer}, {milk, diapers, cola} each appear in 2 transactions ✓.
{bread, milk, cola} is pruned without counting, because {bread, cola} is infrequent.
L4: the only candidate-like combos contain an infrequent subset (e.g. {bread, milk, diapers, beer} needs {bread, milk, beer}, which has count 1), so the algorithm stops.
Example (mlxtend)
# pip install mlxtend
from mlxtend.preprocessing import TransactionEncoder
from mlxtend.frequent_patterns import apriori, association_rules
transactions = [
["bread", "milk"],
["bread", "diapers", "beer", "eggs"],
["milk", "diapers", "beer", "cola"],
["bread", "milk", "diapers", "beer"],
["bread", "milk", "diapers", "cola"],
]
te = TransactionEncoder()
basket = pd.DataFrame(te.fit(transactions).transform(transactions), columns=te.columns_)
freq = apriori(basket, min_support=0.4, use_colnames=True)
print(freq.sort_values("support", ascending=False))
# Note: newer mlxtend versions require num_itemsets; remove that argument
# if your (older) version raises an error about it.
rules = association_rules(freq, num_itemsets=len(basket), metric="confidence", min_threshold=0.7)
print(rules[["antecedents", "consequents", "support", "confidence", "lift"]])
Characteristics
| Strengths | Limits |
|---|---|
| Easy to understand and implement | Generates many candidates: costly for large/dense data |
| Pruning shrinks the search space | Multiple database scans (one per level) |
Good for sparse data with high min_support | Slow when min_support is low or itemsets are long |
16. ECLAT Algorithm
Idea
ECLAT (Equivalence Class Clustering and bottom-up Lattice Traversal) uses a vertical data format: instead of "each transaction lists its items", it stores "each item lists the transactions it appears in" (a TID-set). The support of an itemset is then found by intersecting TID-sets.
Horizontal (Apriori) Vertical (ECLAT)
T1: bread, milk bread → {T1, T2, T4, T5}
T2: bread, diapers, beer, eggs milk → {T1, T3, T4, T5}
T3: milk, diapers, beer, cola diapers → {T2, T3, T4, T5}
T4: bread, milk, diapers, beer beer → {T2, T3, T4}
T5: bread, milk, diapers, cola cola → {T3, T5}
eggs → {T2}
Support of {bread, milk} = size of {T1,T2,T4,T5} ∩ {T1,T3,T4,T5} = {T1,T4,T5} → 3 transactions.
The algorithm
1. Convert the database to vertical format (item → TID-set).
2. Drop items with |TID-set| < min_support count.
3. DEPTH-FIRST search: take an item, combine it with each later item by
intersecting TID-sets; if the result still meets min support, recurse
deeper with that new itemset as the prefix.
4. Backtrack when no extension remains frequent.
Example (a compact pure-Python implementation)
def eclat(transactions, min_support):
n = len(transactions)
min_count = min_support * n
# Build vertical format
vertical = {}
for tid, t in enumerate(transactions):
for item in t:
vertical.setdefault(item, set()).add(tid)
items = sorted(((i, tids) for i, tids in vertical.items() if len(tids) >= min_count),
key=lambda x: x[0])
frequent = {}
def recurse(prefix, candidates):
for idx, (item, tids) in enumerate(candidates):
new_set = prefix + (item,)
frequent[new_set] = len(tids) / n # support
extensions = []
for other_item, other_tids in candidates[idx + 1:]:
inter = tids & other_tids # TID-set intersection
if len(inter) >= min_count:
extensions.append((other_item, inter))
if extensions:
recurse(new_set, extensions)
recurse((), items)
return frequent
result = eclat(transactions, min_support=0.4)
for itemset, sup in sorted(result.items(), key=lambda kv: (-kv[1], kv[0])):
print(itemset, round(sup, 2))
(Frequent itemsets from ECLAT can be fed into the same rule-generation step as Apriori's.)
Characteristics
| Strengths | Limits |
|---|---|
| Support counting = fast set intersections | TID-sets can become large in dense data (memory) |
| Few passes over the data | Intersections get expensive when TID-sets are huge |
| Depth-first search uses less candidate storage than Apriori | Can still blow up with a very low min_support |
| Efficient on sparse data | Less commonly available in ready-made libraries |
Optimization: the diffset variant stores only the differences between TID-sets, which saves memory on dense data.
17. FP-Growth Algorithm
Idea
FP-Growth (Frequent Pattern Growth) finds frequent itemsets without generating candidates. It compresses the whole database into a compact FP-tree (prefix tree), then mines the tree recursively. It is usually much faster than Apriori.
Step 1: Build the FP-tree (two scans of the database)
Scan 1: count every item; drop infrequent ones; sort items by frequency (descending).
Scan 2: for each transaction, sort its items in that same order and insert it
into the tree. Shared prefixes share nodes (their counts are incremented).
A header table links all nodes of the same item.
Example tree (min support count = 2; eggs dropped; order: bread, milk, diapers, beer, cola):
root
├── bread:4
│ ├── milk:3
│ │ └── diapers:2
│ │ ├── beer:1
│ │ └── cola:1
│ └── diapers:1
│ └── beer:1
└── milk:1
└── diapers:1
└── beer:1
└── cola:1
Transactions that share a prefix (e.g. many start with bread → milk) are stored once, which is where the compression comes from.
Step 2: Mine the tree (divide and conquer)
For each item (starting from the LEAST frequent):
1. Collect its CONDITIONAL PATTERN BASE: all prefix paths leading to that item,
each with that item's count.
2. Build a CONDITIONAL FP-TREE from those paths (keeping only items that remain frequent).
3. Recursively mine it, adding the item to the patterns found.
For example, for cola, the prefix paths are bread → milk → diapers (count 1) and milk → diapers → beer (count 1). Within these, milk and diapers each have count 2, so {milk, cola}, {diapers, cola} and {milk, diapers, cola} are frequent, matching the Apriori results.
Example (mlxtend)
from mlxtend.frequent_patterns import fpgrowth, association_rules
freq_fp = fpgrowth(basket, min_support=0.4, use_colnames=True)
rules_fp = association_rules(freq_fp, num_itemsets=len(basket), metric="lift", min_threshold=1.0)
print(rules_fp[["antecedents", "consequents", "support", "confidence", "lift"]]
.sort_values("lift", ascending=False))
Both apriori and fpgrowth return the same frequent itemsets; FP-Growth just gets there faster.
Characteristics
| Strengths | Limits |
|---|---|
| No candidate generation | The FP-tree can be big if there is little prefix sharing (sparse, diverse data) |
| Only two database scans | More complex to implement than Apriori |
| Compact tree compresses data; much faster on large datasets | Tree may not fit in memory for massive data |
Apriori vs ECLAT vs FP-Growth (summary)
| Apriori | ECLAT | FP-Growth | |
|---|---|---|---|
| Strategy | Generate-and-test, BFS | TID-set intersection, DFS | FP-tree, pattern growth |
| Data format | Horizontal | Vertical | Compressed tree |
| Candidates | Many | Via intersections | None |
| Scans | Many | Few | 2 |
| Speed (typical) | Slowest | Fast on sparse data | Fastest overall |
| Memory risk | Candidate sets | Large TID-sets | Large tree |
| Simplicity | Simplest | Simple | Most complex |
18. Market Basket Analysis
What it is
Market Basket Analysis (MBA) is the best-known application of association rules. It analyzes what customers buy together in the same transaction (basket) to uncover purchasing patterns.
"Customers who buy A also tend to buy B."
Why businesses use it
| Use | Example action |
|---|---|
| Cross-selling | Recommend B when a customer adds A to the cart |
| Store layout | Place related items nearby (or deliberately far apart to encourage browsing) |
| Bundling and promotions | Create combo offers; discount A to lift B sales |
| Product recommendations | "Frequently bought together" |
| Inventory planning | Stock complementary items together |
| Catalog / website design | Group related products on pages and emails |
The workflow
1. COLLECT transaction data (one row per item per transaction, or per-basket lists).
2. CLEAN remove returns/cancelled orders, fix item naming, decide item granularity.
3. ENCODE into a one-hot basket matrix (rows = transactions, columns = items, True/False).
4. MINE frequent itemsets (Apriori / FP-Growth / ECLAT) with a suitable min_support.
5. GENERATE association rules; filter by confidence and lift.
6. INTERPRET rank, sanity-check, and turn the strongest rules into business actions.
7. TEST A/B test any change (promotion, layout, recommendation) before scaling it.
Preparing the data from a transaction log
# A typical "long" table: one row per purchased item
log = pd.DataFrame({
"invoice": [1, 1, 2, 2, 2, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5],
"item": ["bread", "milk",
"bread", "diapers", "beer",
"milk", "diapers", "beer",
"bread", "milk", "diapers", "beer",
"bread", "milk", "diapers", "cola"],
})
# One-hot basket matrix: rows = invoices, columns = items
basket = (log.assign(present=True)
.pivot_table(index="invoice", columns="item", values="present",
aggfunc="max", fill_value=False)
.astype(bool))
Mining and ranking rules
from mlxtend.frequent_patterns import fpgrowth, association_rules
freq = fpgrowth(basket, min_support=0.4, use_colnames=True)
rules = association_rules(freq, num_itemsets=len(basket), metric="lift", min_threshold=1.0)
# Keep strong, interesting rules
strong = rules[(rules["confidence"] >= 0.7) & (rules["lift"] > 1.1)]
strong = strong.sort_values(["lift", "confidence"], ascending=False)
print(strong[["antecedents", "consequents", "support", "confidence", "lift", "leverage", "conviction"]])
How to read a rule
Take {diapers} ⇒ {beer} with support 0.60, confidence 0.75, lift 1.25:
"60% of all baskets contain both. When a basket has diapers, 75% of the time it also has beer, which is 1.25× more often than you'd expect if the two were unrelated."
Choosing thresholds
| Setting | Too low | Too high |
|---|---|---|
| min_support | Huge number of itemsets and rules; slow; noisy | Misses interesting but less-common combinations |
| min_confidence | Many weak, unreliable rules | Few rules |
| min_lift | Includes coincidental rules | Only very strong patterns |
Start with a modest min_support and raise it if the output explodes; then filter by confidence and lift.
Pitfalls and good practice
- Association ≠ causation. Discounting diapers won't necessarily make people buy beer; test it.
- High confidence can be trivial (popular items). Check lift (and leverage/conviction).
- Rare items can have huge lift from just a few transactions; ensure enough support.
- Item granularity matters: "milk" vs "2% organic milk 1L" gives very different results. Group items into sensible categories when SKUs are too fine.
- Mind the basket definition: a transaction might be a receipt, a customer-day, or a session.
- Rules can be redundant (many rules say the same thing); consolidate and pick actionable ones.
- Patterns change over time or by season; refresh the analysis regularly and consider segmenting by store, region, or customer type.
- Obvious rules (e.g. hot dogs ⇒ buns) are valid but unsurprising; look for the non-obvious, actionable ones.
Cheat Sheets and Decision Guides
Clustering algorithm comparison
| Algorithm | Needs k? | Cluster shape | Handles noise | Varying density | Soft output | Scales to big data | Key parameter(s) |
|---|---|---|---|---|---|---|---|
| K-Means | Yes | Spherical | No | No | No | Good | k |
| K-Medoids | Yes | Spherical-ish | Robust | No | No | Poor–Moderate | k, distance |
| Agglomerative | Optional (cut tree) | Depends on linkage | Linkage-dependent | Linkage-dependent | No | Poor | linkage, cut level |
| Divisive | Optional | Depends | Moderate | Moderate | No | Poor | split rule |
| DBSCAN | No | Arbitrary | Yes | No | No | Moderate–Good | eps, min_samples |
| HDBSCAN | No | Arbitrary | Yes | Yes | Probabilities | Moderate | min_cluster_size |
| OPTICS | No | Arbitrary | Yes | Yes | No | Moderate | min_samples, xi |
| Mean Shift | No | Arbitrary (mode-based) | Fairly | Limited | No | Poor | bandwidth |
| GMM | Yes | Elliptical | No | Partly | Yes (probabilities) | Moderate | k, covariance type |
| Fuzzy C-Means | Yes | Spherical | No | No | Yes (memberships) | Moderate | c, m |
| Spectral | Yes | Non-convex | No | Limited | No | Poor | k, affinity |
How to choose a clustering method
Round, similar-sized blobs and a big dataset?
└─► K-Means (MiniBatchKMeans if huge)
Outliers present, or you want real data points as centres / a custom distance?
└─► K-Medoids
Want to see structure at multiple levels (a tree) and don't know k?
└─► Hierarchical (Agglomerative with Ward / average linkage)
Arbitrary shapes + noise, similar density?
└─► DBSCAN
Arbitrary shapes + noise, varying densities?
└─► HDBSCAN (or OPTICS for the reachability view)
Don't want to pick k and want mode-seeking clusters, data is small?
└─► Mean Shift
Overlapping, elliptical clusters, or you need probabilities?
└─► GMM
Overlap with graded membership (degree of belonging)?
└─► Fuzzy C-Means
Non-convex shapes defined by connectivity (rings, moons)?
└─► Spectral Clustering
Evaluation cheat sheet
| Situation | Use |
|---|---|
| No labels | Silhouette, Davies–Bouldin, Calinski–Harabasz, elbow (inertia), stability, profiling; BIC/AIC for GMM |
| Have true labels | ARI, NMI, homogeneity / completeness / V-measure, Fowlkes–Mallows |
| Always | Visual inspection + domain sense-check |
Association rule metric cheat sheet
| Metric | Quick meaning | Good sign |
|---|---|---|
| Support | How common is the itemset? | High enough to matter |
| Confidence | How reliable is A ⇒ B? | High (but beware popular B) |
| Lift | Better than chance? | > 1 |
| Leverage | Excess co-occurrence | > 0 |
| Conviction | Strength of dependence | > 1 |
Frequent-itemset algorithm choice
Learning / small data / simplicity → Apriori
Sparse data, set-intersection speed → ECLAT
Large datasets, best general speed → FP-Growth
Key Takeaways
- Clustering groups unlabelled data. Always scale features, and remember that results depend on the distance measure and algorithm assumptions.
- K-Means is the fast default but assumes round clusters and needs
k; K-Medoids swaps means for real points to resist outliers. - Hierarchical clustering gives a dendrogram you can cut anywhere; agglomerative (bottom-up) is standard, divisive (top-down) is rarer.
- Density methods (DBSCAN, HDBSCAN, OPTICS, Mean Shift) find arbitrary shapes and don't need
k; HDBSCAN/OPTICS handle varying densities. - GMM and Fuzzy C-Means give soft assignments; spectral clustering handles connectivity-based, non-convex shapes.
- There's no single right answer in clustering: combine internal metrics, external metrics (if labels exist), stability checks, and domain judgment.
- Association rules find "if A then B" co-occurrence patterns via frequent itemsets + rules, relying on the downward-closure property to prune.
- Apriori is simple but slow, ECLAT uses TID-set intersections, and FP-Growth avoids candidate generation with an FP-tree.
- In market basket analysis, don't trust confidence alone; check lift, ensure enough support, interpret carefully, and validate with experiments.
#Model Evaluation and Validation#
Training a model is easy. Knowing whether it's any good is the hard part. A model that scores 100% on the data it has already seen can still fail badly on new data. Evaluation and validation are the tools that tell you how a model will really behave in the wild, why it fails, and how to improve it.
These notes are organized in five parts:
| Part | Question it answers | Sections |
|---|---|---|
| A. Fit and generalization | Is my model too simple, too complex, or just right? | 1–3 |
| B. Validation strategies | How do I estimate performance honestly? | 4–8 |
| C. Classification metrics | How good are my class predictions? | 9–15 |
| D. Regression metrics | How good are my numeric predictions? | 16–20 |
| E. Going beyond a single number | Are the probabilities trustworthy, what threshold, what does a mistake cost, where does it fail? | 21–24 |
Table of Contents
- Overfitting and Underfitting
- Bias-Variance Trade-off
- Generalization and Model Complexity
- Holdout Validation
- K-Fold Cross-Validation
- Stratified K-Fold
- Leave-One-Out Cross-Validation (LOOCV)
- Time Series Cross-Validation
- Confusion Matrix
- Accuracy, Precision, Recall and F1-score
- Specificity and Sensitivity
- ROC Curve and AUC
- Precision-Recall Curve
- Log Loss
- Matthews Correlation Coefficient (MCC)
- Mean Absolute Error (MAE)
- Mean Squared Error (MSE)
- Root Mean Squared Error (RMSE)
- R-squared and Adjusted R-squared
- Mean Absolute Percentage Error (MAPE)
- Calibration and Probability Estimates
- Threshold Tuning
- Cost-Sensitive Evaluation
- Error Analysis
- Cheat Sheets
Setup used in the code examples
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.datasets import make_classification, make_regression
from sklearn.model_selection import (train_test_split, KFold, StratifiedKFold, LeaveOneOut,
TimeSeriesSplit, RepeatedStratifiedKFold,
cross_val_score, cross_validate, cross_val_predict,
learning_curve, validation_curve, GridSearchCV)
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression, LinearRegression
from sklearn.tree import DecisionTreeClassifier
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import (confusion_matrix, accuracy_score, precision_score, recall_score,
f1_score, fbeta_score, classification_report, roc_curve, roc_auc_score,
precision_recall_curve, average_precision_score, log_loss,
matthews_corrcoef, balanced_accuracy_score, brier_score_loss,
mean_absolute_error, mean_squared_error, r2_score,
mean_absolute_percentage_error)
# Imbalanced binary classification data (about 85% / 15%)
X, y = make_classification(n_samples=2000, n_features=12, n_informative=6, n_redundant=2,
weights=[0.85, 0.15], flip_y=0.02, random_state=42)
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, stratify=y, random_state=42)
# Regression data
Xr, yr = make_regression(n_samples=600, n_features=8, n_informative=5, noise=20, random_state=42)
Xr_train, Xr_test, yr_train, yr_test = train_test_split(Xr, yr, test_size=0.2, random_state=42)
PART A: Fit and Generalization
1. Overfitting and Underfitting
The core goal
A model should perform well on new, unseen data, not just the data it was trained on. Two things can go wrong.
Underfitting (too simple)
The model is too simple to capture the real pattern. It performs poorly on both training and new data.
- Example: fitting a straight line to clearly curved data.
- Cause: not enough model capacity, too few or poor features, too much regularization, too little training.
Overfitting (too complex)
The model memorizes the training data, including its noise, instead of learning the general pattern. It performs very well on training data but poorly on new data.
- Example: a deep, unpruned decision tree that gets every training point right.
- Cause: too much capacity, too little data, too many noisy features, training too long.
Underfit Good fit Overfit
(misses the curve) (captures the trend) (chases every wiggle)
y │ · · · y │ · · · y │ · · ·
│ ────────── │ ╱‾‾╲ · · │ ╱╲╱╲ · ·
│ · · · │ ╱ · ╲ · │ ╱ ╲/╲╱
└─────────── x └─────────── x └─────────── x
How to spot them
| Training performance | Validation / test performance | Gap | |
|---|---|---|---|
| Underfitting | Poor | Poor | Small |
| Good fit | Good | Good (similar) | Small |
| Overfitting | Excellent | Much worse | Large |
Diagnosing with a validation curve
Vary one complexity setting and compare training vs validation scores.
depths = np.arange(1, 21)
train_scores, val_scores = validation_curve(
DecisionTreeClassifier(random_state=42), X_train, y_train,
param_name="max_depth", param_range=depths, cv=5, scoring="f1")
plt.plot(depths, train_scores.mean(axis=1), "o-", label="Training")
plt.plot(depths, val_scores.mean(axis=1), "o-", label="Validation")
plt.xlabel("max_depth (model complexity →)"); plt.ylabel("F1"); plt.legend(); plt.show()
- Left side (shallow trees): both scores low → underfitting.
- Right side (deep trees): training near perfect, validation drops → overfitting.
- The peak of the validation curve is the sweet spot.
Diagnosing with a learning curve
Vary the amount of training data and watch both scores.
sizes, tr, va = learning_curve(
DecisionTreeClassifier(max_depth=4, random_state=42), X_train, y_train,
cv=5, train_sizes=np.linspace(0.1, 1.0, 8), scoring="f1")
plt.plot(sizes, tr.mean(axis=1), "o-", label="Training")
plt.plot(sizes, va.mean(axis=1), "o-", label="Validation")
plt.xlabel("Training set size"); plt.ylabel("F1"); plt.legend(); plt.show()
| Learning-curve pattern | Diagnosis | What helps |
|---|---|---|
| Both curves low and converged | High bias (underfitting) | More complex model, better features; more data won't help much |
| Big gap: training high, validation low, gap shrinking with more data | High variance (overfitting) | More data, regularization, simpler model |
| Both high and close together | Good fit | Done |
Remedies
| Problem | Things to try |
|---|---|
| Underfitting | More expressive model; add or engineer features; reduce regularization; train longer |
| Overfitting | More training data; simpler model; regularization (L1/L2, pruning, min_samples_leaf); fewer/better features; early stopping; ensembling (bagging); cross-validation for tuning |
2. Bias-Variance Trade-off
The decomposition
For a given point, a model's expected squared prediction error splits into three parts:
Expected error = Bias² + Variance + Irreducible noise
| Component | Meaning | High value means |
|---|---|---|
| Bias | How far the model's average prediction (over many different training sets) is from the truth | Model is too simple; systematically wrong (underfitting) |
| Variance | How much the predictions change when trained on different samples of data | Model is too sensitive to the training sample (overfitting) |
| Irreducible noise | Randomness inherent in the data | Can't be removed by any model |
The dartboard picture
Low bias, low variance Low bias, HIGH variance HIGH bias, low variance HIGH bias, HIGH variance
( ● ) ● ● ● ● ● ●
(●●●●●) ( ● ● ) ( ●●● ) ← tight but ( ● )
( ● ) ● ● off-centre ● ●
tight AND centred centred but scattered scattered AND off-centre
The trade-off
As you increase model complexity:
- Bias goes down (the model can fit more patterns).
- Variance goes up (the model becomes more sensitive to noise).
Total error is U-shaped: too simple → high bias; too complex → high variance; the minimum sits in between.
Error
▲
│ \ ╱ ← Variance
│ \ Total error ╱
│ \ ╱‾‾‾‾‾‾‾‾‾╲ ╱
│ ╲ ╱ ★ ╲ ╱
│ ╲ ╱ (sweet spot) ╲ ╱
│ ╲╱__ Bias² __________╳________
└──────────────────────────────────────► Model complexity
underfit overfit
Typical examples
| Model / setting | Tends toward |
|---|---|
| Linear regression on nonlinear data | High bias |
Deep unpruned tree, KNN with k = 1 | High variance |
| Strong regularization | Higher bias, lower variance |
| Bagging / Random Forest | Reduces variance |
| Boosting | Reduces bias (but can overfit if run too long) |
| More training data | Reduces variance |
A simulation that makes it concrete
Train the same type of model on many different random datasets, then measure how far the average prediction is from the truth (bias) and how much predictions scatter (variance).
from sklearn.preprocessing import PolynomialFeatures
rng = np.random.default_rng(0)
true_f = lambda x: np.sin(2 * np.pi * x)
x_grid = np.linspace(0, 1, 200).reshape(-1, 1)
for degree in [1, 4, 15]:
preds = []
for _ in range(200): # 200 different training sets
x = rng.uniform(0, 1, 30)
y_noisy = true_f(x) + rng.normal(0, 0.3, 30)
model = make_pipeline(PolynomialFeatures(degree), LinearRegression())
model.fit(x.reshape(-1, 1), y_noisy)
preds.append(model.predict(x_grid))
preds = np.array(preds)
bias_sq = np.mean((preds.mean(axis=0) - true_f(x_grid.ravel())) ** 2)
variance = np.mean(preds.var(axis=0))
print(f"degree {degree:>2}: bias² = {bias_sq:.3f} variance = {variance:.3f}")
You should see degree 1 → high bias, low variance, and degree 15 → low bias, high variance.
3. Generalization and Model Complexity
Generalization
Generalization is a model's ability to perform well on new data drawn from the same distribution as the training data. It is the real goal of machine learning.
Generalization gap = error on unseen data − error on training data
- Training error tells you how well the model fits what it has seen.
- Test (generalization) error tells you how well it will perform in practice.
- A small gap with low test error is the target.
What is "model complexity"?
Complexity (or capacity) is a model's flexibility to fit patterns. It's controlled by:
| Model | Complexity knobs (higher = more complex) |
|---|---|
| Linear / polynomial regression | Number of features, polynomial degree |
| Regularized linear models | Lower regularization strength (alpha ↓, C ↑) |
| Decision tree | max_depth ↑, min_samples_leaf ↓ |
| KNN | Smaller k |
| Random Forest / boosting | More/deeper trees; more boosting rounds, higher learning rate |
| SVM | Larger C, larger RBF gamma |
| Any model | Number of input features |
How generalization relates to complexity
Error
▲
│ Test error: \ ╱
│ \____ ____╱
│ ‾‾‾‾ ← minimum = best generalization
│ Training error: \
│ \_____
│ \______ keeps falling
└────────────────────────────────────► Complexity
- Training error always keeps decreasing with complexity.
- Test error falls, then rises. Pick the complexity at its minimum.
Ways to improve generalization
| Technique | How it helps |
|---|---|
| More (representative) data | Harder to memorize; better signal |
| Regularization | Penalizes complexity (L1, L2, pruning, dropout-style constraints) |
| Cross-validation | Honest estimate for choosing complexity |
| Early stopping | Stop training when validation error starts rising |
| Feature selection / simpler models | Fewer ways to fit noise |
| Ensembling (bagging) | Averages away variance |
| Data augmentation / noise | Makes patterns more robust |
Practical principles
- Never judge a model on its training data.
- Prefer the simplest model that performs well enough (Occam's razor); simpler models are usually more stable and explainable.
- Keep a final test set untouched until the very end, so your reported performance is not optimistic.
- Beware of data leakage (information from the test data influencing training, e.g. scaling on the full dataset before splitting). Use pipelines.
PART B: Validation Strategies
All validation strategies answer the same question: "How will this model perform on data it has not seen?" They differ in how they split the data.
4. Holdout Validation
Idea
Split the data once into separate parts:
┌──────────────────────────────┬──────────────┬──────────────┐
│ TRAIN (60–70%) │ VALIDATION │ TEST │
│ fit the model │ (15–20%) │ (15–20%) │
│ │ tune/compare │ final report │
└──────────────────────────────┴──────────────┴──────────────┘
| Set | Purpose |
|---|---|
| Training | Fit the model's parameters |
| Validation | Tune hyperparameters, compare models, choose thresholds |
| Test | One final, unbiased performance estimate (touched once) |
A simple two-way split (train/test) is the minimal version; it's fine if you do not tune anything.
Example
# 60% train / 20% validation / 20% test
X_temp, X_test, y_temp, y_test = train_test_split(X, y, test_size=0.20, stratify=y, random_state=42)
X_tr, X_val, y_tr, y_val = train_test_split(X_temp, y_temp, test_size=0.25, stratify=y_temp, random_state=42)
# 0.25 of the remaining 80% = 20% of the total
model = RandomForestClassifier(random_state=42).fit(X_tr, y_tr)
print("Validation F1:", f1_score(y_val, model.predict(X_val))) # used for decisions
# ...tune, pick the final model, THEN evaluate once:
print("Test F1 :", f1_score(y_test, model.predict(X_test)))
Good practices
- Stratify for classification so each split keeps class proportions.
- Set
random_statefor reproducibility. - Split before preprocessing/feature selection, and fit scalers/encoders on the training set only.
- For time-ordered data, don't shuffle (see section 8).
- If the same entity (e.g. one patient or customer) has many rows, keep all its rows in one split.
Strengths and limits
| Strengths | Limits |
|---|---|
| Simple, fast (train once) | Result depends on which rows landed in each split (high variance) |
| Works well with large datasets | Wastes data: the held-out part isn't used for training |
| Mimics real deployment | Unreliable on small datasets or rare classes |
| Repeated tuning on the validation set can overfit to it |
5. K-Fold Cross-Validation
Idea
Instead of one split, divide the data into K equal folds. Train K times; each time use a different fold as the validation set and the other K−1 folds for training. Average the K scores.
K = 5
Fold 1: [VAL ][train][train][train][train] → score₁
Fold 2: [train][VAL ][train][train][train] → score₂
Fold 3: [train][train][VAL ][train][train] → score₃
Fold 4: [train][train][train][VAL ][train] → score₄
Fold 5: [train][train][train][train][VAL ] → score₅
Final estimate = mean(score₁ … score₅) ± standard deviation
Every observation is used for validation exactly once and for training K−1 times, so the estimate is more stable and data-efficient than a single holdout.
Example
model = make_pipeline(StandardScaler(), LogisticRegression(max_iter=1000))
kf = KFold(n_splits=5, shuffle=True, random_state=42)
scores = cross_val_score(model, X_train, y_train, cv=kf, scoring="f1")
print("Fold scores:", scores.round(3))
print(f"Mean F1 = {scores.mean():.3f} (± {scores.std():.3f})")
Several metrics at once, plus training scores:
res = cross_validate(model, X_train, y_train, cv=kf,
scoring=["accuracy", "precision", "recall", "f1", "roc_auc"],
return_train_score=True)
pd.DataFrame(res).mean()
Out-of-fold predictions (one prediction per row, each made by a model that never saw that row):
oof_pred = cross_val_predict(model, X_train, y_train, cv=kf)
oof_proba = cross_val_predict(model, X_train, y_train, cv=kf, method="predict_proba")[:, 1]
Choosing K
| K | Bias of estimate | Variance of estimate | Cost |
|---|---|---|---|
| Small (e.g. 3) | Higher (less training data per fold) | Lower | Cheaper |
| 5 or 10 (common choice) | Good balance | Good balance | Moderate |
| Large (up to N, i.e. LOOCV) | Lowest | Can be higher | Expensive |
Variants and extras
| Variant | Use |
|---|---|
shuffle=True | Randomizes row order before splitting (use unless the data has an order that matters) |
Repeated K-Fold (RepeatedKFold) | Repeats K-Fold with different shuffles for a steadier estimate |
Group K-Fold (GroupKFold) | Keeps all rows of a group (e.g. one patient) in the same fold to avoid leakage |
| Nested cross-validation | An inner loop tunes hyperparameters; an outer loop estimates performance (an honest estimate when tuning) |
# Nested CV: inner loop = tuning, outer loop = evaluation
inner = StratifiedKFold(n_splits=3, shuffle=True, random_state=1)
outer = StratifiedKFold(n_splits=5, shuffle=True, random_state=2)
grid = GridSearchCV(RandomForestClassifier(random_state=42),
param_grid={"max_depth": [3, 6, None], "min_samples_leaf": [1, 5]},
cv=inner, scoring="f1")
nested_scores = cross_val_score(grid, X_train, y_train, cv=outer, scoring="f1")
print("Nested CV F1:", nested_scores.mean().round(3))
Important rules
- All preprocessing inside the pipeline so each fold fits scalers/encoders only on its own training portion.
- CV is for model selection and estimation; keep a final test set for the last check.
- The spread (standard deviation) across folds is informative: large spread = unstable model or small data.
6. Stratified K-Fold
The problem it solves
With imbalanced classes, plain K-Fold can create folds with very different class ratios, even folds with no minority examples, which makes scores noisy or undefined.
Idea
Stratified K-Fold keeps the class proportions in every fold approximately equal to those of the full dataset.
Full data: 90% class 0, 10% class 1
Plain K-Fold (unlucky): Stratified K-Fold:
Fold 1: 99% / 1% Fold 1: 90% / 10%
Fold 2: 82% / 18% Fold 2: 90% / 10%
Fold 3: 95% / 5% Fold 3: 90% / 10%
Example
skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
for fold, (tr_idx, va_idx) in enumerate(skf.split(X_train, y_train), 1):
print(f"Fold {fold}: positive share in validation = {y_train[va_idx].mean():.3f}")
scores = cross_val_score(RandomForestClassifier(random_state=42), X_train, y_train,
cv=skf, scoring="f1")
print("Mean F1:", scores.mean().round(3))
In scikit-learn, passing an integer
cv=5tocross_val_scoreorGridSearchCVwith a classifier automatically uses stratified folds.
Repeated stratified K-Fold (more reliable estimates for small or imbalanced data):
rskf = RepeatedStratifiedKFold(n_splits=5, n_repeats=3, random_state=42)
scores = cross_val_score(RandomForestClassifier(random_state=42), X_train, y_train, cv=rskf, scoring="f1")
print(len(scores), "scores; mean =", scores.mean().round(3))
When to use it
- Always for classification, especially imbalanced or multiclass problems.
- Not for regression (there are no classes), although continuous targets can be binned for a similar effect.
- Make sure each class has at least
Kexamples, or some folds won't contain it.
7. Leave-One-Out Cross-Validation (LOOCV)
Idea
LOOCV is K-Fold with K = N (the number of samples). Each iteration leaves out exactly one observation as the validation set and trains on all the rest.
N = 5 samples
Iteration 1: [VAL][train][train][train][train]
Iteration 2: [train][VAL][train][train][train]
... → N models are trained
Iteration 5: [train][train][train][train][VAL]
Example
from sklearn.linear_model import Ridge
# LOOCV needs N model fits, so use a small dataset
Xs, ys = Xr_train[:100], yr_train[:100]
loo = LeaveOneOut()
scores = cross_val_score(Ridge(alpha=1.0), Xs, ys, cv=loo, scoring="neg_mean_absolute_error")
print("LOOCV MAE:", -scores.mean())
Each fold's validation set is a single point, so metrics like R² (which need several points) aren't defined per fold. Use per-sample errors (MAE, MSE, accuracy), or collect predictions with
cross_val_predictand compute the metric once.
Properties
| Property | LOOCV |
|---|---|
| Bias | Very low (training sets are almost the full data) |
| Variance of the estimate | Can be high (the N training sets are nearly identical, so the errors are highly correlated) |
| Cost | N model fits: expensive for big data or slow models |
| Determinism | No randomness: the same result every time |
When to use it
- Very small datasets where every sample matters.
- Models with a fast closed-form shortcut (e.g. linear regression via the PRESS statistic).
- Avoid on large datasets; 5- or 10-fold is nearly as good and far cheaper.
Related: Leave-P-Out (LeavePOut) leaves out p samples each time, but the number of combinations explodes quickly.
8. Time Series Cross-Validation
Why ordinary CV breaks for time data
Time-ordered data has temporal dependence. Randomly shuffled folds would train on the future to predict the past, which is leakage, giving misleadingly good scores that vanish in production.
Golden rule: the training data must always come before the validation data in time.
Idea: forward-chaining (expanding window)
Time ────────────────────────────────────────────────►
Split 1: [ train ][ val ]
Split 2: [ train ][ val ]
Split 3: [ train ][ val ]
Split 4: [ train ][ val ]
Split 5: [ train ][ val ]
Each split trains on all data up to a point and validates on the next block.
Example
from sklearn.linear_model import Ridge
# Synthetic series: trend + seasonality + noise
rng = np.random.default_rng(0)
t = np.arange(300)
series = pd.Series(0.05 * t + 5 * np.sin(2 * np.pi * t / 30) + rng.normal(0, 1, 300))
# Lag features use only PAST values
feat = pd.DataFrame({"lag1": series.shift(1), "lag2": series.shift(2), "lag30": series.shift(30)}).dropna()
target = series.loc[feat.index]
tscv = TimeSeriesSplit(n_splits=5)
for i, (tr, va) in enumerate(tscv.split(feat), 1):
print(f"Split {i}: train 0–{tr[-1]} | validate {va[0]}–{va[-1]}")
scores = cross_val_score(Ridge(), feat, target, cv=tscv, scoring="neg_mean_absolute_error")
print("MAE per split:", (-scores).round(2), "| mean:", (-scores.mean()).round(2))
Useful options of TimeSeriesSplit
| Option | Effect |
|---|---|
n_splits | Number of train/validation pairs |
test_size | Fixed length of each validation block |
gap | Rows skipped between train and validation (avoids leakage when features use rolling windows or the target is a multi-step-ahead forecast) |
max_train_size | Caps training length → a rolling (sliding) window instead of an expanding one |
Rolling window (max_train_size set):
Split 1: [ train ][ val ]
Split 2: [ train ][ val ]
Split 3: [ train ][ val ]
Expanding vs rolling window
| Expanding | Rolling | |
|---|---|---|
| Training data | Grows with each split | Fixed size, slides forward |
| Good when | Patterns are stable; more history helps | Patterns drift; old data becomes irrelevant |
Pitfalls
- Never shuffle time series data.
- Build features (lags, rolling means) using only information available at prediction time.
- Use the last chunk of time as the final test set.
- Report performance per split too; error often varies with time (e.g. seasonality, regime shifts).
PART C: Classification Metrics
9. Confusion Matrix
What it is
A table that compares actual vs predicted classes. It's the foundation for nearly every classification metric.
Binary classification
PREDICTED
Negative Positive
┌────────────┬────────────┐
ACTUAL Neg │ TN │ FP │ FP = False Positive (false alarm, Type I error)
├────────────┼────────────┤
Pos │ FN │ TP │ FN = False Negative (miss, Type II error)
└────────────┴────────────┘
| Cell | Meaning | Example (disease test) |
|---|---|---|
| TP (True Positive) | Predicted positive, truly positive | Sick person correctly flagged |
| TN (True Negative) | Predicted negative, truly negative | Healthy person correctly cleared |
| FP (False Positive) | Predicted positive, truly negative | Healthy person wrongly flagged |
| FN (False Negative) | Predicted negative, truly positive | Sick person missed |
Running example used in the next sections
A fraud model scores 1,000 transactions (50 truly fraudulent, 950 legitimate):
Predicted
Legit Fraud
Actual Legit │ 850 │ 100 │ (TN = 850, FP = 100)
Actual Fraud │ 10 │ 40 │ (FN = 10, TP = 40)
Code
model = RandomForestClassifier(random_state=42).fit(X_train, y_train)
pred = model.predict(X_test)
cm = confusion_matrix(y_test, pred) # rows = actual, columns = predicted
tn, fp, fn, tp = cm.ravel()
print(cm)
print(f"TN={tn} FP={fp} FN={fn} TP={tp}")
# Plot (raw counts, and normalized by actual class)
from sklearn.metrics import ConfusionMatrixDisplay
ConfusionMatrixDisplay.from_predictions(y_test, pred, cmap="Blues")
ConfusionMatrixDisplay.from_predictions(y_test, pred, normalize="true", cmap="Blues") # row-wise rates
plt.show()
scikit-learn's layout: rows = actual, columns = predicted, ordered by class label (so class 0 first, class 1 second).
Multiclass confusion matrix
For K classes you get a K × K grid:
Predicted
Cat Dog Bird
Actual Cat [ 50 3 2 ]
Dog [ 5 40 5 ]
Bird[ 1 4 45 ]
- The diagonal = correct predictions.
- Off-diagonal cells show which classes get confused (here, dogs are sometimes called cats or birds).
- Per-class TP/FP/FN are read from rows and columns: for class c, TP = diagonal cell; FN = rest of row c; FP = rest of column c.
Why it matters
- It shows the kind of mistakes, not just how many.
- Normalizing by row (actual class) shows per-class recall; normalizing by column shows per-class precision.
10. Accuracy, Precision, Recall and F1-score
All four come straight from the confusion matrix (running example: TP = 40, FP = 100, FN = 10, TN = 850).
Accuracy
Accuracy = (TP + TN) / (TP + TN + FP + FN) = (40 + 850) / 1000 = 0.89
What fraction of all predictions were correct?
The accuracy trap: with 95% negatives, a model that always says "negative" gets 95% accuracy while catching nothing. Accuracy is only trustworthy when classes are reasonably balanced and errors cost about the same.
Precision (positive predictive value)
Precision = TP / (TP + FP) = 40 / 140 = 0.286
Of everything I flagged as positive, how much was really positive? Low precision = many false alarms. Prioritize it when false positives are costly (spam filter marking real mail as spam, wrongly freezing accounts).
Recall (sensitivity, true positive rate)
Recall = TP / (TP + FN) = 40 / 50 = 0.80
Of all real positives, how many did I catch? Low recall = many misses. Prioritize it when missing a case is costly (disease screening, fraud detection).
F1-score
The harmonic mean of precision and recall; it is high only when both are high.
F1 = 2 · Precision · Recall / (Precision + Recall) = 2·0.286·0.80 / (0.286 + 0.80) ≈ 0.421
The harmonic mean punishes imbalance: precision 1.0 with recall 0.01 gives F1 ≈ 0.02, not 0.5.
F-beta score: weighting recall vs precision
Fβ = (1 + β²) · P · R / (β² · P + R)
| β | Emphasis |
|---|---|
| 1 | Precision and recall equally (F1) |
| 2 | Recall matters more (F2) |
| 0.5 | Precision matters more (F0.5) |
The precision–recall tension
Raising the decision threshold usually raises precision and lowers recall; lowering it does the opposite. You can't maximize both; choose by the cost of each error type (see section 22).
Multiclass averaging
| Averaging | How | Use when |
|---|---|---|
| Macro | Compute per class, then plain average | All classes equally important (exposes weak small classes) |
| Weighted | Per-class average weighted by class size | Reflect class frequencies |
| Micro | Pool all TP/FP/FN, then compute | Overall performance (micro-F1 = accuracy for single-label multiclass) |
Code
print("Accuracy :", accuracy_score(y_test, pred))
print("Precision:", precision_score(y_test, pred))
print("Recall :", recall_score(y_test, pred))
print("F1 :", f1_score(y_test, pred))
print("F2 :", fbeta_score(y_test, pred, beta=2))
print(classification_report(y_test, pred, digits=3)) # per-class precision, recall, F1, support
# multiclass: f1_score(y, pred, average="macro" | "weighted" | "micro")
11. Specificity and Sensitivity
These two terms come from medical testing and describe performance on each class separately.
Definitions
| Term | Formula | Plain meaning | Running example |
|---|---|---|---|
| Sensitivity (= recall, TPR) | TP / (TP + FN) | Of the positives, how many did we detect? | 40 / 50 = 0.80 |
| Specificity (TNR) | TN / (TN + FP) | Of the negatives, how many did we correctly clear? | 850 / 950 = 0.895 |
| False Positive Rate (FPR) | FP / (FP + TN) = 1 − Specificity | Of the negatives, how many were falsely flagged? | 100 / 950 = 0.105 |
| False Negative Rate (FNR) | FN / (FN + TP) = 1 − Sensitivity | Of the positives, how many were missed? | 10 / 50 = 0.20 |
Sensitivity ── "Don't miss sick people." (guards against false negatives)
Specificity ── "Don't alarm healthy people." (guards against false positives)
Related measures
| Measure | Formula | Meaning |
|---|---|---|
| Balanced accuracy | (Sensitivity + Specificity) / 2 | Accuracy that isn't fooled by class imbalance: (0.80 + 0.895)/2 = 0.847 |
| Youden's J | Sensitivity + Specificity − 1 | A single index (0 = useless, 1 = perfect); used to pick ROC thresholds |
| Negative Predictive Value (NPV) | TN / (TN + FN) | When the model says "negative", how often is it right? (850 / 860 ≈ 0.988) |
Trade-off
Like precision and recall, sensitivity and specificity trade off through the threshold: a lower threshold catches more positives (higher sensitivity) but flags more negatives (lower specificity).
Where each is typically prioritized
| Situation | Prefer |
|---|---|
| Screening for a serious, treatable disease | High sensitivity (don't miss cases; follow up with confirmatory tests) |
| Confirming a diagnosis before risky treatment | High specificity (avoid false alarms) |
Code
tn, fp, fn, tp = confusion_matrix(y_test, pred).ravel()
sensitivity = tp / (tp + fn)
specificity = tn / (tn + fp)
print("Sensitivity:", round(sensitivity, 3))
print("Specificity:", round(specificity, 3))
print("Balanced accuracy:", balanced_accuracy_score(y_test, pred))
# Specificity also equals recall of the NEGATIVE class:
print(recall_score(y_test, pred, pos_label=0))
12. ROC Curve and AUC
The ROC curve
The Receiver Operating Characteristic curve plots True Positive Rate (sensitivity) against False Positive Rate (1 − specificity) at every possible threshold.
TPR
1.0 │ ╭────────── ★ near-perfect model (hugs top-left)
│ ╭──╯
│ ╭─╯ ╱ ← diagonal = random guessing
│ ╭─╯ ╱
│ ╭─╯ ╱
│ ╱ ╱
0.0 └──────────────────── FPR
0.0 1.0
- Each point on the curve = one threshold.
- Top-left corner = ideal (high TPR, low FPR).
- The diagonal is a model with no skill.
- Moving along the curve trades sensitivity for specificity.
AUC: Area Under the Curve
A single number summarizing the whole curve.
| AUC | Interpretation |
|---|---|
| 1.0 | Perfect ranking |
| 0.9 – 1.0 | Excellent |
| 0.8 – 0.9 | Good |
| 0.7 – 0.8 | Fair |
| 0.5 | Random guessing |
| < 0.5 | Worse than random (predictions are inverted) |
Probabilistic meaning: AUC is the probability that the model assigns a higher score to a randomly chosen positive than to a randomly chosen negative. So it measures ranking quality, independent of any threshold and of probability calibration.
Code
proba = model.predict_proba(X_test)[:, 1]
fpr, tpr, thresholds = roc_curve(y_test, proba)
auc = roc_auc_score(y_test, proba)
plt.plot(fpr, tpr, label=f"Model (AUC = {auc:.3f})")
plt.plot([0, 1], [0, 1], "k--", label="Random")
plt.xlabel("False Positive Rate"); plt.ylabel("True Positive Rate"); plt.legend(); plt.show()
# Pick the threshold that maximizes Youden's J (= TPR − FPR)
j = tpr - fpr
best_threshold = thresholds[j.argmax()]
print("Threshold with the best Youden's J:", round(best_threshold, 3))
Multiclass: roc_auc_score(y, proba_matrix, multi_class="ovr", average="macro") computes one-vs-rest AUC per class.
Strengths and limits
| Strengths | Limits |
|---|---|
| Threshold-independent comparison of models | Can look overly optimistic with heavy class imbalance, because FPR is diluted by the large number of negatives |
| Insensitive to class proportions in the sense that TPR and FPR are each computed within a class | Says nothing about calibration of probabilities |
| Easy to interpret as ranking ability | Treats all regions of the curve equally, even thresholds you'd never use |
For highly imbalanced problems where the positive class is what matters, the precision-recall curve is usually more informative (next section).
13. Precision-Recall Curve
The curve
The Precision-Recall (PR) curve plots precision (y-axis) against recall (x-axis) across all thresholds. It focuses entirely on the positive class and ignores true negatives.
Precision
1.0 │●╲
│ ╲___
│ ╲___ ← typical shape: precision falls as recall rises
│ ╲___
0.15│- - - - - - - -╲_____ ← baseline = fraction of positives (random classifier)
└──────────────────────── Recall
0.0 1.0
- Top-right corner = ideal (high precision and high recall).
- A no-skill model produces a horizontal line at the positive-class prevalence (e.g. 0.15 if 15% are positive). Compare your curve to this baseline, not to 0.5.
Average Precision (AP) / PR-AUC
A summary of the curve:
AP = Σ (Rₙ − Rₙ₋₁) · Pₙ (precision weighted by the increase in recall at each threshold)
It is preferred over a trapezoidal area under the PR curve, which can be overly optimistic because precision does not interpolate linearly.
Code
precision, recall, thresholds = precision_recall_curve(y_test, proba)
ap = average_precision_score(y_test, proba)
plt.plot(recall, precision, label=f"Model (AP = {ap:.3f})")
plt.axhline(y_test.mean(), color="k", linestyle="--", label=f"Baseline = {y_test.mean():.2f}")
plt.xlabel("Recall"); plt.ylabel("Precision"); plt.legend(); plt.show()
# Threshold that maximizes F1
f1 = 2 * precision[:-1] * recall[:-1] / (precision[:-1] + recall[:-1] + 1e-12)
print("Best-F1 threshold:", thresholds[f1.argmax()].round(3))
ROC vs PR: which to use?
| ROC / AUC | PR curve / AP | |
|---|---|---|
| Axes | TPR vs FPR | Precision vs Recall |
| Uses true negatives? | Yes (through FPR) | No |
| Random baseline | Diagonal (AUC 0.5) | Horizontal at prevalence |
| Sensitive to imbalance? | Less (can look rosy) | Yes (more honest for rare positives) |
| Best for | Balanced data; overall ranking ability | Imbalanced data, when positives are what matter (fraud, rare disease, retrieval) |
Rule of thumb: if the positive class is rare and the cost of false positives matters, look at the PR curve first.
14. Log Loss
What it is
Log loss (cross-entropy / logistic loss) evaluates the predicted probabilities, not just the final labels. It rewards being confident and correct and punishes being confident and wrong very severely.
Formula
Binary:
Log loss = −(1/N) · Σ [ yᵢ · ln(pᵢ) + (1 − yᵢ) · ln(1 − pᵢ) ]
where pᵢ is the predicted probability of the positive class.
Multiclass: −(1/N) · Σᵢ Σₖ yᵢₖ · ln(pᵢₖ), where only the probability assigned to the true class contributes.
Feel for the numbers
For a sample whose true label is 1:
| Predicted probability for class 1 | Loss −ln(p) | Comment |
|---|---|---|
| 0.99 | 0.010 | Confident and right: tiny loss |
| 0.90 | 0.105 | |
| 0.50 | 0.693 | Unsure (random guess baseline) |
| 0.10 | 2.303 | Confident and wrong: large loss |
| 0.01 | 4.605 | Very confident and wrong: huge loss |
| → 0 | → ∞ | Probabilities are clipped internally to avoid infinity |
Interpretation
- Lower is better; the minimum is 0 (perfect, fully confident correct predictions).
- A model that always predicts the class prevalence
πhas loss−[π·ln π + (1−π)·ln(1−π)]. For a balanced binary problem that is ln 2 ≈ 0.693. Your model should beat this baseline. - It is the loss function that logistic regression and many boosted models minimize, so it's a natural validation metric for them.
Why use it?
| Advantage | Detail |
|---|---|
| Measures probability quality | Two models with the same accuracy can have very different log loss |
| Smooth and sensitive | Improves even when labels don't change |
| Catches overconfidence | A few confident mistakes dominate the score |
Code
print("Log loss:", log_loss(y_test, model.predict_proba(X_test)))
# Compare to the baseline that always predicts the prevalence
prev = y_train.mean()
baseline = log_loss(y_test, np.full(len(y_test), prev))
print("Baseline (prevalence) log loss:", round(baseline, 3))
Log loss depends heavily on calibration (see section 21). Uncalibrated, overconfident models score badly even if their rankings (AUC) are good.
15. Matthews Correlation Coefficient (MCC)
What it is
MCC is a single score that uses all four cells of the confusion matrix. It is the correlation between the actual and predicted labels.
MCC = (TP·TN − FP·FN) / √[ (TP+FP)(TP+FN)(TN+FP)(TN+FN) ]
Interpretation
| MCC | Meaning |
|---|---|
| +1 | Perfect prediction |
| 0 | No better than random guessing |
| −1 | Total disagreement (perfectly inverted predictions) |
Why it's valued
Accuracy, precision, recall and F1 can each look good while another aspect is bad. MCC is high only if the model does well on both classes, so it stays honest under class imbalance.
Running example (TP = 40, FP = 100, FN = 10, TN = 850):
MCC = (40·850 − 100·10) / √(140 · 50 · 950 · 860)
= 33,000 / 75,625 ≈ 0.44
Accuracy = 0.89 (looks great) F1 = 0.42 MCC = 0.44 (tells the real story)
A degenerate case: predict "negative" for everything on 95/5 data → accuracy 95%, but TP = FP = 0, so the MCC denominator is zero and MCC is reported as 0, correctly signalling "no skill".
MCC vs F1
| F1 | MCC | |
|---|---|---|
| Uses TN? | No | Yes |
| Symmetric between classes? | No (depends on which class is "positive") | Yes (swapping the class labels gives the same value) |
| Range | 0 to 1 | −1 to 1 |
| Imbalance behaviour | Good | Very good |
Code
print("MCC:", matthews_corrcoef(y_test, pred))
MCC also generalizes to multiclass problems (matthews_corrcoef handles them directly).
PART D: Regression Metrics
Let yᵢ be the actual value, ŷᵢ the prediction, and eᵢ = yᵢ − ŷᵢ the error (residual); N = number of samples.
A tiny example used below: actual [3, −0.5, 2, 7], predicted [2.5, 0.0, 2, 8] → errors [0.5, −0.5, 0, −1].
16. Mean Absolute Error (MAE)
MAE = (1/N) · Σ |yᵢ − ŷᵢ|
Example: (0.5 + 0.5 + 0 + 1) / 4 = 0.5
| Property | Detail |
|---|---|
| Units | Same as the target (e.g. dollars, degrees) |
| Interpretation | "On average, predictions are off by MAE" |
| Outliers | Robust: every error counts in proportion to its size |
| What it estimates | Related to the median (the best constant prediction under MAE is the median) |
| Weakness | Not smooth at 0 (matters for some optimizers); doesn't punish big errors extra |
mae = mean_absolute_error(yr_test, LinearRegression().fit(Xr_train, yr_train).predict(Xr_test))
Use it when: you want an easily explained average error and large errors shouldn't dominate.
17. Mean Squared Error (MSE)
MSE = (1/N) · Σ (yᵢ − ŷᵢ)²
Example: (0.25 + 0.25 + 0 + 1) / 4 = 0.375
| Property | Detail |
|---|---|
| Units | Squared units of the target (harder to interpret) |
| Outliers | Very sensitive: squaring makes large errors count disproportionately |
| What it estimates | Related to the mean (the best constant under MSE is the mean) |
| Math | Smooth and differentiable, so it's the standard training loss for many models (e.g. OLS) |
| Link to theory | The expected MSE decomposes into bias² + variance + noise (see section 2) |
mse = mean_squared_error(yr_test, pred_r)
Use it when: large errors are especially undesirable and you want them punished strongly, or when comparing models during training/optimization.
18. Root Mean Squared Error (RMSE)
RMSE = √MSE = √[ (1/N) · Σ (yᵢ − ŷᵢ)² ]
Example: √0.375 ≈ 0.612
| Property | Detail |
|---|---|
| Units | Same as the target, which makes it easier to interpret than MSE |
| Outliers | Sensitive (still based on squared errors) |
| Relation to MAE | RMSE ≥ MAE always |
MAE vs RMSE: what the gap tells you
Errors: [1, 1, 1, 1, 1] → MAE = 1.0, RMSE = 1.0 (all errors equal)
Errors: [0, 0, 0, 0, 5] → MAE = 1.0, RMSE = 2.24 (one big error)
- RMSE ≈ MAE → errors are fairly uniform.
- RMSE ≫ MAE → a few large errors dominate. Investigate outliers (see error analysis).
Code
reg = LinearRegression().fit(Xr_train, yr_train)
pred_r = reg.predict(Xr_test)
print("MAE :", mean_absolute_error(yr_test, pred_r))
print("MSE :", mean_squared_error(yr_test, pred_r))
print("RMSE:", np.sqrt(mean_squared_error(yr_test, pred_r)))
Quick comparison
| MAE | MSE | RMSE | |
|---|---|---|---|
| Units | Target units | Squared units | Target units |
| Outlier sensitivity | Low | High | High |
| Penalizes big errors | Linearly | Quadratically | Quadratically |
| Easy to explain | Yes | No | Yes |
| Typical use | Reporting, robust evaluation | Training loss | Reporting where big errors matter |
19. R-squared and Adjusted R-squared
R² (coefficient of determination)
The proportion of variance in the target that the model explains, compared to a baseline that always predicts the mean.
R² = 1 − SS_res / SS_tot
SS_res = Σ (yᵢ − ŷᵢ)² (the model's squared errors)
SS_tot = Σ (yᵢ − ȳ)² (squared errors of just predicting the mean)
| R² | Meaning |
|---|---|
| 1 | Perfect predictions |
| 0 | No better than predicting the mean |
| Negative | Worse than predicting the mean (possible, especially on test data) |
| 0.75 | Model explains 75% of the variance |
Things to know
- R² has no units and is easy to compare across datasets of different scales, but a "good" value depends on the field (noisy human behaviour vs physics).
- Training R² never decreases when you add features, even useless ones, so it can reward overfitting. Evaluate on test data / cross-validation.
- High R² ≠ a good model. A model can have high R² and still have biased or wrongly specified structure; check residual plots.
- R² doesn't tell you the size of errors in real units; pair it with MAE/RMSE.
Adjusted R²
Adjusts R² for the number of predictors, penalizing features that add little.
Adjusted R² = 1 − (1 − R²) · (N − 1) / (N − p − 1)
N = number of samples, p = number of predictors (features)
Example: R² = 0.80, N = 50
Predictors p | Adjusted R² |
|---|---|
| 5 | 1 − 0.20 · 49/44 ≈ 0.777 |
| 20 | 1 − 0.20 · 49/29 ≈ 0.662 |
Same raw R², but the model with more predictors gets a lower adjusted R², penalizing the extra complexity.
| R² | Adjusted R² | |
|---|---|---|
| Adding a useless feature | Stays the same or rises slightly | Can fall |
| Adding a useful feature | Rises | Rises (if the gain outweighs the penalty) |
| Can be negative? | Yes | Yes |
| Best for | Overall goodness of fit | Comparing models with different numbers of features |
Code
r2 = r2_score(yr_test, pred_r)
def adjusted_r2(r2, n_samples, n_features):
return 1 - (1 - r2) * (n_samples - 1) / (n_samples - n_features - 1)
print("R² :", round(r2, 3))
print("Adjusted R²:", round(adjusted_r2(r2, len(yr_test), Xr_test.shape[1]), 3))
Adjusted R² is mainly a tool for linear models evaluated in-sample. For other models, rely on cross-validated R² and error metrics.
20. Mean Absolute Percentage Error (MAPE)
MAPE = (100% / N) · Σ | (yᵢ − ŷᵢ) / yᵢ |
Example: actual [100, 200, 400], predicted [110, 150, 440] → percentage errors 10%, 25%, 10% → MAPE = 15%.
Why people like it
- Scale-free and intuitive: "predictions are off by about 15% on average."
- Lets you compare accuracy across series of different magnitudes.
Weaknesses (important!)
| Problem | Explanation |
|---|---|
| Undefined / explosive near zero | If an actual value is 0 (or tiny), the percentage error is infinite or enormous |
| Asymmetric | Over-predictions can have unlimited percentage error, but under-predictions are capped at 100%, so MAPE favours models that under-predict |
| Unequal weighting | Errors on small actual values dominate the average |
| Not meaningful for targets that can be negative or have an arbitrary zero (e.g. temperature in °C) |
Alternatives when MAPE is unsuitable
| Metric | Idea |
|---|---|
| sMAPE (symmetric MAPE) | Divides by the average of actual and predicted values (reduces asymmetry; still problematic near zero) |
| WAPE / WMAPE | `Σ |
| MAE / RMSE | Use absolute units when percentages are unstable |
Code
actual = np.array([100, 200, 400])
forecast = np.array([110, 150, 440])
mape = mean_absolute_percentage_error(actual, forecast) # returns a FRACTION (0.15)
print(f"MAPE: {mape * 100:.1f}%")
wape = np.abs(actual - forecast).sum() / np.abs(actual).sum()
print(f"WAPE: {wape * 100:.1f}%")
scikit-learn's
mean_absolute_percentage_errorreturns a fraction, not a percent, and replaces zero actuals with a tiny number, producing huge values. Check your data for zeros first.
PART E: Going Beyond a Single Number
21. Calibration and Probability Estimates
The question
A model says "80% chance". Is it really right about 80% of the time for such predictions? That is calibration.
A well-calibrated model's predicted probabilities match observed frequencies: among all cases predicted at ~0.7, about 70% are actually positive.
Why it matters
- Decisions based on probabilities (risk scores, expected cost, "show only if > 90% sure") require trustworthy numbers.
- Calibration is separate from discrimination (ranking): a model can have excellent AUC yet badly miscalibrated probabilities.
- Needed to combine predictions, compute expected costs, and set meaningful thresholds (sections 22–23).
The reliability diagram (calibration curve)
Group predictions into bins by predicted probability; plot mean predicted probability (x) vs actual fraction of positives (y).
Observed
frequency
1.0 │ ╱ ← perfectly calibrated = diagonal
│ ○ ╱
│ ○ ╱
│ ○ ╱ Below the diagonal: OVERCONFIDENT
│ ○ ╱ (predicts higher than reality)
0.0 └───────────────── Predicted probability
Above the diagonal: UNDERCONFIDENT
Measuring calibration
| Measure | Idea |
|---|---|
| Brier score | Mean squared difference between predicted probability and the 0/1 outcome; lower is better; reflects both calibration and discrimination |
| Log loss | Also sensitive to calibration (section 14) |
| Expected Calibration Error (ECE) | Weighted average gap between predicted and observed frequency across bins |
Typical behaviour of common models
| Model | Usual tendency |
|---|---|
| Logistic regression | Generally well calibrated |
| Naive Bayes | Overconfident (probabilities pushed toward 0 and 1) |
| Random Forest, bagged trees | Often underconfident near 0 and 1 (averaging pulls probabilities toward the middle) |
| SVM, boosted trees | Scores are not probabilities / sigmoid-shaped distortion; often need calibration |
| Any model trained with class weights or resampling | Probabilities are shifted relative to the real-world rate |
Fixing calibration (post-hoc)
| Method | How | Best when |
|---|---|---|
Platt scaling (method="sigmoid") | Fits a logistic curve mapping scores → probabilities | Small datasets; distortion looks sigmoidal |
Isotonic regression (method="isotonic") | Fits a flexible monotonic step function | Larger datasets (can overfit with little data) |
Calibrate on data the model wasn't trained on (cross-validation inside CalibratedClassifierCV, or a held-out calibration set), and check on a separate test set.
Code
from sklearn.calibration import calibration_curve, CalibratedClassifierCV, CalibrationDisplay
from sklearn.naive_bayes import GaussianNB
nb = GaussianNB().fit(X_train, y_train)
nb_cal = CalibratedClassifierCV(GaussianNB(), method="sigmoid", cv=5).fit(X_train, y_train)
for name, m in [("Naive Bayes (raw)", nb), ("Naive Bayes (calibrated)", nb_cal)]:
p = m.predict_proba(X_test)[:, 1]
print(f"{name:26s} Brier = {brier_score_loss(y_test, p):.4f} | LogLoss = {log_loss(y_test, p):.4f}")
# Reliability diagram
fig, ax = plt.subplots(figsize=(5, 5))
CalibrationDisplay.from_estimator(nb, X_test, y_test, n_bins=10, ax=ax, name="Raw")
CalibrationDisplay.from_estimator(nb_cal, X_test, y_test, n_bins=10, ax=ax, name="Calibrated")
plt.show()
# Expected Calibration Error (manual)
def expected_calibration_error(y_true, proba, n_bins=10):
y_true = np.asarray(y_true)
bins = np.linspace(0, 1, n_bins + 1)
idx = np.digitize(proba, bins[1:-1])
ece = 0.0
for b in range(n_bins):
mask = idx == b
if mask.any():
ece += mask.mean() * abs(y_true[mask].mean() - proba[mask].mean())
return ece
print("ECE (raw):", expected_calibration_error(y_test, nb.predict_proba(X_test)[:, 1]).round(4))
Key points
- Calibration doesn't change which class is predicted at the 0.5 threshold much, but it makes the numbers meaningful.
- A monotonic calibration map (like Platt scaling) preserves ranking (AUC stays the same).
- Re-check calibration when the data distribution or class prevalence shifts.
22. Threshold Tuning
The problem
Most classifiers output a probability or score. Turning it into a label needs a threshold (default 0.5):
predict positive if P(positive | x) ≥ threshold
0.5 is rarely optimal. It's right only when classes are balanced, probabilities are calibrated, and both error types cost the same.
Effect of moving the threshold
| Lower threshold (e.g. 0.2) | Higher threshold (e.g. 0.8) | |
|---|---|---|
| Predicted positives | More | Fewer |
| Recall / sensitivity | ↑ | ↓ |
| Precision | ↓ | ↑ |
| False positives | ↑ | ↓ |
| False negatives | ↓ | ↑ |
| Specificity | ↓ | ↑ |
Sliding the threshold traces out the ROC and PR curves: each point on them is one threshold.
Ways to choose a threshold
| Strategy | How |
|---|---|
| Maximize a metric | e.g. the threshold giving the best F1 or F-beta |
| Youden's J | Maximize TPR − FPR on the ROC curve |
| Constraint-based | e.g. "highest precision subject to recall ≥ 95%", or "recall at ≤ 5% FPR" |
| Cost-based | Minimize expected cost using the cost of each error type (next section) |
| Capacity-based | Flag the top N% only (e.g. reviewers can check 100 cases a day) |
Manual search
proba_val = model.predict_proba(X_val)[:, 1]
rows = []
for t in np.arange(0.05, 0.96, 0.05):
pred_t = (proba_val >= t).astype(int)
rows.append({"threshold": round(t, 2),
"precision": precision_score(y_val, pred_t, zero_division=0),
"recall": recall_score(y_val, pred_t),
"f1": f1_score(y_val, pred_t),
"mcc": matthews_corrcoef(y_val, pred_t)})
table = pd.DataFrame(rows)
print(table.loc[table["f1"].idxmax()])
Satisfy a constraint (e.g. recall ≥ 0.90, then maximize precision):
precision, recall, thr = precision_recall_curve(y_val, proba_val)
ok = recall[:-1] >= 0.90
best_t = thr[ok][precision[:-1][ok].argmax()]
print("Threshold:", round(best_t, 3))
Built-in tool (scikit-learn ≥ 1.5)
from sklearn.model_selection import TunedThresholdClassifierCV
tuned = TunedThresholdClassifierCV(
RandomForestClassifier(random_state=42),
scoring="f1", # or "balanced_accuracy", or a custom cost scorer (section 23)
cv=5,
).fit(X_train, y_train)
print("Chosen threshold:", tuned.best_threshold_)
print("Test F1:", f1_score(y_test, tuned.predict(X_test)))
Rules of good practice
- Tune the threshold on validation data or via cross-validation, never on the test set; then report test performance once.
- The best threshold depends on class prevalence and calibration. Re-tune if the data shifts or if you recalibrate.
- Threshold tuning changes only the decision rule, not the model's ranking ability (AUC stays the same).
- For multiclass, you can apply per-class thresholds or decision rules, but it's more involved.
- Document the business reason for the chosen threshold.
23. Cost-Sensitive Evaluation
The idea
Standard metrics treat all errors as equally bad. In reality they often aren't:
| Domain | False negative costs... | False positive costs... |
|---|---|---|
| Fraud detection | A fraudulent transaction goes through (large loss) | Blocking a good customer (annoyance, review time) |
| Cancer screening | A missed diagnosis (potentially fatal) | Extra tests, anxiety |
| Spam filter | Spam in the inbox (minor) | Important mail lost (major) |
| Loan approval | Approving a defaulter | Rejecting a good borrower (lost profit) |
Cost-sensitive evaluation judges models by the actual cost (or profit) of their decisions.
The cost matrix
Predicted
Negative Positive
Actual Negative │ C_TN (≈0) │ C_FP │
Actual Positive │ C_FN │ C_TP (≈0)│
Total cost over a dataset:
Total cost = FP · C_FP + FN · C_FN (+ TP · C_TP + TN · C_TN, often zero or negative = benefit)
Example: C_FN = $500 (missed fraud), C_FP = $10 (review cost). With the running example (FN = 10, FP = 100):
Total cost = 10·500 + 100·10 = $6,000, mostly from the 10 misses, even though false positives are ten times more numerous.
The cost-optimal threshold
If probabilities are well calibrated and correct predictions cost nothing, predict positive when the expected cost of doing so is lower than the cost of not doing so:
p · C_FN > (1 − p) · C_FP ⟹ threshold t* = C_FP / (C_FP + C_FN)
| Costs | Optimal threshold |
|---|---|
C_FP = C_FN | 0.5 |
C_FN = 10 · C_FP | 1/11 ≈ 0.09 (be aggressive about flagging) |
C_FP = 10 · C_FN | 10/11 ≈ 0.91 (be very sure before flagging) |
This is why calibration (section 21) matters: the formula is only valid for trustworthy probabilities.
Code
C_FP, C_FN = 10, 500
def total_cost(y_true, y_pred, c_fp=C_FP, c_fn=C_FN):
tn, fp, fn, tp = confusion_matrix(y_true, y_pred, labels=[0, 1]).ravel()
return fp * c_fp + fn * c_fn
# 1) Compare cost across thresholds
proba_val = model.predict_proba(X_val)[:, 1]
thresholds = np.linspace(0.01, 0.99, 99)
costs = [total_cost(y_val, (proba_val >= t).astype(int)) for t in thresholds]
print("Cost-minimizing threshold:", thresholds[int(np.argmin(costs))].round(2),
"| theoretical (if calibrated):", round(C_FP / (C_FP + C_FN), 3))
# 2) Use cost as a cross-validation scoring function
from sklearn.metrics import make_scorer
cost_scorer = make_scorer(total_cost, greater_is_better=False) # lower cost is better
scores = cross_val_score(RandomForestClassifier(random_state=42), X_train, y_train,
cv=StratifiedKFold(5, shuffle=True, random_state=42), scoring=cost_scorer)
print("Mean cost per fold:", (-scores).mean())
Related ideas
| Idea | Detail |
|---|---|
| Profit / utility curves | Plot expected profit vs threshold; pick the maximum |
| Cost-sensitive learning | Using class_weight or sample_weight during training so the model itself accounts for unequal costs (evaluation should still use the real cost metric) |
| Cost-weighted accuracy / expected cost per prediction | Normalize total cost by the number of cases to compare across datasets |
| Asymmetric errors in regression | Under-prediction and over-prediction can cost different amounts (e.g. under-stocking vs over-stocking); evaluate with asymmetric or business-specific loss |
Practical cautions
- Costs are often estimates; test how sensitive the decision is to cost assumptions (try a range).
- Costs may include non-monetary factors (safety, fairness, trust).
- Report both standard metrics and cost, so stakeholders see technical performance and business impact.
24. Error Analysis
The idea
Metrics tell you how much the model is wrong. Error analysis tells you where, why, and what to fix. It means systematically studying the model's mistakes instead of just chasing the score.
A step-by-step process
1. COLLECT gather predictions on validation data (preferably out-of-fold) with the true labels
2. SPLIT separate the errors (FP, FN / large residuals) from the correct cases
3. INSPECT read actual examples; look for patterns
4. CATEGORIZE group errors into causes (e.g. label noise, rare cases, missing feature)
5. QUANTIFY count how often each cause occurs and how much it costs
6. PRIORITIZE choose fixes by (frequency × impact) / effort
7. ACT & RE-MEASURE fix, retrain, and verify the error type shrank
Techniques
1. Look at the confusion matrix
- Binary: Are errors mostly FP or FN?
- Multiclass: which class pairs are confused most (largest off-diagonal cells)? Do they look similar in reality (maybe the labels are ambiguous)?
2. Slice analysis (performance by segment)
Overall metrics can hide weak segments. Compute metrics per subgroup:
results = X_test_df.copy() # a DataFrame with the test features
results["y_true"] = y_test
results["y_pred"] = model.predict(X_test)
results["proba"] = model.predict_proba(X_test)[:, 1]
results["error"] = (results["y_true"] != results["y_pred"]).astype(int)
# Error rate by a categorical segment (e.g. region, device, customer type)
print(results.groupby("segment")["error"].agg(["mean", "count"]).sort_values("mean", ascending=False))
# Error rate across bins of a numeric feature
results["feat_bin"] = pd.qcut(results["feature_0"], q=5)
print(results.groupby("feat_bin", observed=True)["error"].mean())
Look for segments with high error rate and meaningful size; also check performance across demographic or other sensitive groups where relevant.
3. Examine the most confident mistakes
Errors where the model was very sure but wrong are the most informative: they often reveal label errors, leakage, or missing features.
wrong = results[results["error"] == 1].copy()
wrong["confidence_in_wrong"] = np.where(wrong["y_pred"] == 1, wrong["proba"], 1 - wrong["proba"])
print(wrong.sort_values("confidence_in_wrong", ascending=False).head(10))
4. Residual analysis (regression)
resid = yr_test - pred_r
fig, ax = plt.subplots(1, 3, figsize=(15, 4))
ax[0].scatter(pred_r, resid, alpha=0.5); ax[0].axhline(0, color="r")
ax[0].set(title="Residuals vs predicted", xlabel="Predicted", ylabel="Residual")
ax[1].hist(resid, bins=30); ax[1].set_title("Residual distribution")
ax[2].scatter(yr_test, pred_r, alpha=0.5); ax[2].plot([yr_test.min(), yr_test.max()], [yr_test.min(), yr_test.max()], "r--")
ax[2].set(title="Predicted vs actual", xlabel="Actual", ylabel="Predicted")
plt.show()
# Largest absolute errors
worst = np.argsort(np.abs(resid))[-10:]
| Residual pattern | Suggests |
|---|---|
| Curve or trend vs predicted | Missing nonlinearity / interaction |
| Funnel shape (spread grows) | Non-constant variance; consider transforming the target |
| Errors concentrated at high/low targets | Poor extrapolation at the extremes |
| A few huge residuals | Outliers, data errors, or rare events |
| Residuals correlated with a feature | That feature's effect is mis-modelled |
5. Diagnose bias vs variance
Use learning/validation curves (section 1) to decide whether to add data, add complexity, or regularize.
6. Check for data problems
| Suspicion | Check |
|---|---|
| Label noise | Manually review high-confidence errors; are the labels actually wrong? |
| Data leakage | Suspiciously perfect scores; features that wouldn't exist at prediction time |
| Distribution shift | Compare feature distributions of train vs test (or recent vs old data) |
| Duplicates across train/test | Check for overlapping rows |
| Missing/incorrect values concentrated in the errors | Compare missingness among errors vs non-errors |
7. Compare against a baseline and across models
- Which examples do all models get wrong (probably data or label problems) vs only one model (a modelling issue)?
- Compare with a trivial baseline (majority class, mean prediction) so you know the gain is real.
Build an error taxonomy
Review a sample (e.g. 100 errors) and tally the causes:
| Error category | Count | Share | Fixable? | Possible fix |
|---|---|---|---|---|
| Mislabelled examples | 18 | 18% | Yes | Relabel / clean |
| Rare category rarely seen in training | 27 | 27% | Yes | Collect more data, add feature |
| Ambiguous / genuinely hard | 30 | 30% | Hard | Accept as noise floor |
| Missing information (feature absent) | 15 | 15% | Yes | Engineer / acquire a feature |
| Other | 10 | 10% | ? | Investigate |
Prioritize the categories that are large and fixable; don't spend weeks on the 2% corner case.
Practical tips
- Do error analysis on validation data (or out-of-fold predictions), not the final test set, or you'll start tuning to it.
- Keep a running log of error types and what you changed.
- Re-run after each change to confirm that the targeted error category actually shrank, without growing others.
Cheat Sheets
Validation strategy: which to use?
| Situation | Strategy |
|---|---|
| Large dataset, quick check | Holdout (train / val / test) |
| Medium data, need a reliable estimate | K-Fold (K = 5 or 10) |
| Classification, especially imbalanced | Stratified K-Fold (repeated if small) |
| Very small dataset | LOOCV (or repeated stratified K-Fold) |
| Time-ordered data | Time series split (never shuffle) |
| Grouped rows (same patient/user) | Group K-Fold |
| Tuning hyperparameters + honest estimate | Nested CV, or CV for tuning + untouched test set |
Which metric should I use?
Classification
| Situation | Metric(s) |
|---|---|
| Balanced classes, equal costs | Accuracy (plus F1 as a check) |
| Imbalanced classes | F1, PR-AUC (AP), MCC, balanced accuracy, not accuracy |
| False alarms are costly | Precision, F0.5, specificity |
| Misses are costly | Recall / sensitivity, F2 |
| Overall ranking ability, any threshold | ROC-AUC (balanced) or PR-AUC (rare positives) |
| Quality of predicted probabilities | Log loss, Brier score, calibration curve |
| One robust summary of the whole confusion matrix | MCC |
| Real business impact | Expected cost / profit at a tuned threshold |
Regression
| Situation | Metric(s) |
|---|---|
| Easy-to-explain average error | MAE |
| Large errors especially bad | RMSE (MSE for optimization) |
| Scale-free comparison, no zeros in target | MAPE (or WAPE / sMAPE) |
| Share of variance explained | R² (adjusted R² when comparing feature counts) |
| Always | Residual plots + cross-validated scores |
Classification metrics at a glance
| Metric | Formula | Focus |
|---|---|---|
| Accuracy | (TP + TN) / N | Overall correctness |
| Precision | TP / (TP + FP) | Purity of positive predictions |
| Recall / Sensitivity | TP / (TP + FN) | Coverage of real positives |
| Specificity | TN / (TN + FP) | Coverage of real negatives |
| F1 | 2PR / (P + R) | Balance of precision and recall |
| Balanced accuracy | (Sens + Spec) / 2 | Imbalance-robust accuracy |
| MCC | (TP·TN − FP·FN) / √(...) | Whole confusion matrix, −1 to 1 |
| ROC-AUC | Area under TPR vs FPR | Ranking quality |
| PR-AUC / AP | Area under precision vs recall | Positive-class performance |
| Log loss | −mean[y ln p + (1−y) ln(1−p)] | Probability quality |
Regression metrics at a glance
| Metric | Formula | Units | Outlier sensitivity |
|---|---|---|---|
| MAE | mean(|e|) | Target | Low |
| MSE | mean(e²) | Target² | High |
| RMSE | √MSE | Target | High |
| R² | 1 − SS_res/SS_tot | None | Moderate |
| Adjusted R² | 1 − (1−R²)(N−1)/(N−p−1) | None | Moderate |
| MAPE | mean(|e / y|) × 100 | % | Unstable near 0 |
Common pitfalls checklist
- Evaluating on training data
- Tuning on the test set (or peeking at it repeatedly)
- Data leakage (scaling, imputing, or selecting features before splitting; future information in features)
- Using accuracy on imbalanced data
- Not stratifying classification splits
- Shuffling time series data
- Trusting a single split instead of cross-validation (and ignoring fold-to-fold spread)
- Reporting R² only, with no error in real units
- Using MAPE with zeros or near-zero actuals
- Using 0.5 as the threshold without checking
- Treating predicted scores as probabilities without calibration
- Ignoring cost asymmetry between error types
- Skipping error analysis, so you never learn why it fails
Key Takeaways
- Overfitting = great on training data, poor on new data; underfitting = poor on both. The bias-variance trade-off explains both, and the aim is the complexity that generalizes best.
- Validate honestly: holdout for big data, K-Fold for reliable estimates, Stratified K-Fold for classification, LOOCV for tiny data, time-aware splits for time series. Keep a final test set untouched.
- The confusion matrix is the root of classification metrics. Accuracy misleads under imbalance; use precision, recall, F1, specificity, MCC, and look at ROC-AUC / PR-AUC for threshold-independent views (PR-AUC for rare positives).
- Log loss and the Brier score judge probabilities; calibration makes them trustworthy.
- For regression, pair a robust error (MAE) with a big-error-sensitive one (RMSE); read R² with care (use adjusted R² for different feature counts); avoid MAPE near zero.
- Threshold tuning and cost-sensitive evaluation turn a model's scores into good decisions: choose thresholds on validation data using the real cost of each error.
- Error analysis converts a number into action: slice, inspect, categorize, prioritize, fix, and re-measure.