Logistic Regression

Master binary classification with logistic regression, sigmoid functions, and gradient descent

beginner45 min
On this page

The Big Picture

Linear regression predicts continuous values. But what if we need a yes/no answer? Will a user click an ad? Is an email spam? Logistic regression takes the linear model and squashes its output through a sigmoid function so the result lives between 0 and 1 — giving us a probability.

Linear Regression → Logistic Regression
Linear Model z = w·x + b output z ∈ (-∞,+∞) Sigmoid σ(z) 1 / (1 + e⁻ᶻ) output p ∈ (0,1) Probability P(click) = 0.73 Threshold ≥ 0.5 → Class 1 Threshold < 0.5 → Class 0

AdTech Example: Predicting Ad Clicks

You work at an ad platform. Every time a user loads a web page, you must decide in <100ms which ad to show. You want to predict P(click | user, ad, context) to maximize revenue (you only get paid on clicks). Logistic regression is the industry workhorse here because it's fast, interpretable, and scales to billions of events.

Feature Pipeline → Prediction → Bid Decision
INPUT FEATURES x₁ = user_age_bucket x₂ = hour_of_day x₃ = device_type (mobile=1) x₄ = ad_category_match x₅ = user_past_ctr x₆ = page_context_score x₇ = ad_position Label y = clicked? (0/1) Millions of impression logs LOGISTIC MODEL z = Σ wᵢxᵢ + b p = σ(z) p = 0.12 BID ENGINE Expected Revenue = P(click) × bid_price 0.12 × $2.00 = $0.24 → Show ad if profitable
Why logistic regression in AdTech?
① Inference latency is ~microseconds (important when serving millions of requests/sec).
② Weights are directly interpretable ("mobile users have +0.3 click lift").
③ Well-calibrated probabilities (critical for bid pricing).
④ Easy to retrain daily on fresh data.

The Sigmoid Function

The sigmoid (logistic) function maps any real number to the (0, 1) range. It's the "activation" that converts a raw score into a probability.

Sigmoid Curve: σ(z) = 1 / (1 + e⁻ᶻ)
1.0 0.5 0.0 -4 -2 0 2 4 z σ(0) = 0.5 Key properties: σ(-z) = 1 - σ(z) σ'(z) = σ(z)(1 - σ(z)) Nearly 0 (confident NO) Nearly 1 (confident YES)

In our AdTech example: if the raw score z = w·x + b = 2.1, then σ(2.1) ≈ 0.89, meaning the model predicts an 89% probability of click.

Maximum Likelihood & The Cost Function

The Maximum Likelihood Principle

Given observed data, MLE asks: "Which parameter values make the observed data most probable?" We choose weights w that maximize the probability (likelihood) of seeing our actual click/no-click outcomes.

From Likelihood → to Log-Likelihood → to Cost Function
STEP 1: Likelihood for one sample P(y | x) = p̂ʸ · (1 − p̂)¹⁻ʸ When y=1 → P = p̂ (want p̂ high) | When y=0 → P = 1−p̂ (want p̂ low) STEP 2: Likelihood over all N samples (i.i.d.) L(w) = ∏ᵢ [ p̂ᵢʸⁱ · (1 − p̂ᵢ)¹⁻ʸⁱ ] STEP 3: Take log (products → sums, numerically stable) log L(w) = Σᵢ [ yᵢ log(p̂ᵢ) + (1−yᵢ) log(1−p̂ᵢ) ] STEP 4: Negate & average → Minimizable Cost Function J(w) = − (1/N) Σᵢ [ yᵢ log(p̂ᵢ) + (1−yᵢ) log(1−p̂ᵢ) ]
Why negate? We want to maximize likelihood, but optimization libraries minimize. Negating flips the problem: minimizing the negative log-likelihood = maximizing the likelihood.

Likelihood for a Single Sample: Concrete Examples

To build intuition, let's compute likelihoods for concrete predictions:

Single-Sample Likelihood Examples
Example 1: y=1 (clicked), p̂=0.8 L = p̂ʸ · (1−p̂)¹⁻ʸ = 0.8¹ · 0.2⁰ = 0.8 Prediction was confident & correct → HIGH likelihood ✓ Cost = −log(0.8) ≈ 0.22 (small penalty) Example 2: y=0 (no click), p̂=0.2 L = 0.2⁰ · 0.8¹ = 1 · 0.8 = 0.8 Prediction was confident & correct → HIGH likelihood ✓ Cost = −log(0.8) ≈ 0.22 (small penalty) Example 3: y=0 (no click), p̂=0.8 L = 0.8⁰ · 0.2¹ = 1 · 0.2 = 0.2 Prediction was confident & WRONG → LOW likelihood ✗ Cost = −log(0.2) ≈ 1.61 (BIG penalty!) Example 4: y=1 (clicked), p̂=0.1 L = 0.1¹ · 0.9⁰ = 0.1 · 1 = 0.1 Prediction was confident & WRONG → LOW likelihood ✗ Cost = −log(0.1) ≈ 2.30 (BIG penalty!)
Key insight: Likelihood is highest (closest to 1) when the model is confident AND correct. It's lowest (close to 0) when confident but wrong. The cost function automatically penalizes overconfident wrong predictions heavily.

Why Take the Logarithm?

When we multiply many small probabilities together (the likelihood for all N samples), we get a tiny number that underflows numerically. Taking the log converts products to sums, which is computationally stable.

Numerical Stability: Products vs. Log Sums
WITHOUT LOG (Problem) L(w) = 0.9 × 0.8 × 0.7 × ... × 0.85 For 10,000 samples with p̂ ≈ 0.85: L ≈ 0.85^10000 ≈ 10^−840 Underflow! Gradients → 0 WITH LOG (Solution) log L(w) = log(0.9) + log(0.8) + ... + log(0.85) For 10,000 samples with p̂ ≈ 0.85: log L ≈ 10000 × log(0.85) ≈ −1600 Numerically stable! Gradients flow normally
Monotonicity property: Since log is a monotonic function, argmax_w log L(w) = argmax_w L(w). We haven't changed the optimal weights, only made the computation stable!

Log-Likelihood is Concave (Global Optimum Guaranteed)

For logistic regression, the log-likelihood function is concave in the weights w. This is a powerful property: it means there is exactly one local minimum (which is the global minimum). Gradient descent is guaranteed to find it, regardless of initialization!

Concavity: Why Logistic Regression is Convex
Log-Likelihood Surface (Concave) Single global max Non-Concave Surface (e.g., NN) Many local minima! (no guarantee GD finds global) Why concavity matters for logistic regression: • Any local minimum is the global minimum • Gradient descent will always converge to optimal solution (with proper learning rate) • No risk of getting stuck in poor local optima • In contrast, deep networks are non-convex (much harder optimization)
Intuition: What the cost penalizes for a single sample
When y = 1 (clicked) Cost = −log(p̂) High Low 0 1 p̂ → p̂ low → BIG penalty p̂ high → low penalty When y = 0 (not clicked) Cost = −log(1 − p̂) High Low 0 1 p̂ → p̂ low → low penalty p̂ high → BIG penalty

Cross-Entropy & NCE Loss

Binary Cross-Entropy (BCE)

The cost function we just derived is the Binary Cross-Entropy loss. "Cross-entropy" comes from information theory: it measures the difference between the true distribution (labels) and the predicted distribution (model outputs). Minimizing cross-entropy = making the predicted distribution match the true one.

Cross-Entropy: Connecting Information Theory to Logistic Regression
INFORMATION THEORY VIEW Entropy H(p) measures uncertainty H(p) = −Σ p(x) log p(x) Cross-entropy between p and q: H(p, q) = −Σ p(x) log q(x) • p = true distribution (labels) • q = model's predicted distribution Minimizing H(p,q) → q approaches p = LOGISTIC REGRESSION VIEW For binary case (y ∈ {0,1}): BCE = −[ y log(p̂) + (1−y) log(1−p̂) ] Exact same formula as our negative log-likelihood! MLE ≡ Cross-Entropy Minimization

Categorical Cross-Entropy (Multi-Class Extension)

Binary cross-entropy handles 2 classes (click/no-click). But what if we want to predict which of K ads will be clicked (multi-class problem)? We use Categorical Cross-Entropy, which extends BCE to K classes using the softmax function.

Softmax & Categorical Cross-Entropy: The Multi-Class Analog
SOFTMAX (Multi-Class Sigmoid) For K classes with logits z₁...z_K: p̂ₖ = σ(z)ₖ = e^(z_k) / Σⱼ e^(z_j) Probabilities sum to 1, one per class CATEGORICAL CROSS-ENTROPY For K-class prediction (y = one-hot): H(y, p̂) = −Σ_(c=1)^K y_c log(p̂_c) Only true class contributes (since y_true=1, others=0) Example: Predicting Ad Category (K=3) True label: y = [0, 1, 0] (class 2 is correct) Model predicts: p̂ = [0.2, 0.7, 0.1] H = −(0·log(0.2) + 1·log(0.7) + 0·log(0.1)) = −log(0.7) ≈ 0.36 Model assigned 70% to correct class → small loss ✓
Softmax vs. Sigmoid: Sigmoid is for binary classification (2 classes). Softmax is the generalization for K classes. When K=2, softmax becomes numerically equivalent to sigmoid applied to each class.

Focal Loss: Handling Class Imbalance

In AdTech, click-through rates are often 0.1%–1%: millions of non-clicks but rare clicks. Standard BCE treats all samples equally, so the model gets swamped by easy negative examples. Focal Loss down-weights easy negatives so the model focuses on hard, informative examples.

Focal Loss: Down-Weighting Easy Examples
Focal Loss Formula (for binary classification): FL(p̂, y) = −α(1 − p̂)^γ log(p̂) [when y=1] = −α p̂^γ log(1 − p̂) [when y=0] The Term: (1 − p̂)^γ • If p̂ ≈ 1 (confident correct): (1 − p̂) ≈ 0 → multiplied by ~0 Loss heavily down-weighted (don't waste on easy examples) • If p̂ ≈ 0.5 (uncertain): (1 − p̂) ≈ 0.5 → full weight Hard examples get full penalty (learn from confusion!) Hyperparameters • α (alpha): class balance factor Typical: α = 0.25 (rare class upweighting) • γ (gamma): focusing parameter (usually 2.0) γ = 0 → focal loss = BCE γ = 2 → moderate down-weighting of easy examples γ = 5 → aggressive focus on hard examples
AdTech application: For CTR prediction with 99.9% negative samples, focal loss (γ = 2–5) reduces training time and improves rare click prediction. Standard BCE would waste computation on obviously negative examples.

Noise Contrastive Estimation (NCE)

NCE is used when you have a massive output space (e.g., predicting which of millions of ads to show). Instead of computing softmax over all candidates, NCE reformulates the problem: for each true positive, sample k random negatives, then train a binary logistic classifier to distinguish real from noise.

NCE: Turning a multi-class problem into binary logistic sub-problems
SOFTMAX OVER ALL (expensive) Ad A (true click) ✓ Ad B Ad C ... Ad 10,000,000 Must normalize over ALL NCE NCE: SAMPLE k NEGATIVES (efficient binary classification) ✓ True: Ad A (y=1) ✗ Noise: Ad X₁ (y=0) ✗ Noise: Ad X₂ (y=0) ✗ Noise: Ad X₃ (y=0) Binary Logistic P(real | sample) = σ(score − log k·pₙ) BCE loss on this mini-batch Only k+1 forward passes instead of 10M!

Mathematical Formulation of NCE

NCE estimates a normalized probability by computing the ratio of signal to noise. For a given context (e.g., user), we score a true positive (real ad) versus sampled negatives (random ads).

NCE: Mathematical Derivation & Scoring
NCE Probability: P(D=1 | word, context) P(real | w,c) = σ(s(w,c)) / [σ(s(w,c)) + k · p_n(w)] Key Components • s(w,c) = score function (e.g., w·c) Inner product of word & context embeddings • k = number of negative samples • p_n(w) = noise distribution Usually uniform over vocabulary Negative Sampling Simplification In Word2Vec, the normalization constant is ignored (not explicitly estimated): P(real|w,c) ≈ σ(s(w,c)) Much faster but less principled

Connection to AdTech: Two-Tower Models

In large-scale ad retrieval, NCE is used to train two-tower models: one tower encodes the user context, the other encodes the ad candidate. True impressions (user saw ad + clicked) are positives; random ads are negatives.

Two-Tower Architecture for Ad Retrieval with NCE
USER TOWER Inputs: • user_id • age_bucket • browsing_history • past_CTR Neural Network (embedding layers + hidden layers) user_embedding ∈ R^d AD TOWER Inputs: • ad_id • advertiser • ad_category • creative_features Neural Network (embedding layers + hidden layers) ad_embedding ∈ R^d user_emb ad_emb dot product NCE TRAINING For each impression: s(user, true_ad) = user_emb · ad_emb ✓ Positive: true clicked ad ✗ Negatives (k samples): random ads from catalog Binary CE loss on {positive + k negatives}: L = −log σ(score_pos) − Σ log(1−σ(score_neg)) Train embeddings to maximize positive separation vs. negatives
Two-Tower efficiency: At serving time, user embeddings are pre-computed and cached. For each new query, we only compute the dot product with ads in memory (~milliseconds). Full softmax over millions of ads would be impossible.
AdTech connection: NCE is used in large-scale ad retrieval systems. When you have millions of candidate ads, NCE lets you train the model by sampling a handful of "noise" ads per impression rather than scoring all candidates. Word2Vec also famously uses NCE (called "negative sampling") for the same reason. Two-tower models with NCE achieve sub-100ms retrieval latency for production systems serving billions of daily impressions.

Gradient Descent — Learning the Weights

We have a cost function J(w). Now we need to find the weights that minimize it. There's no closed-form solution for logistic regression, so we use iterative optimization: gradient descent.

Gradient Descent: Walk Downhill on the Cost Surface
J(w) Cost w Start (random w) Minimum (optimal w*) UPDATE RULE w ← w − α · ∂J/∂w α = learning rate (step size) ∂J/∂w = gradient (slope direction)
The Gradient for Logistic Regression
Gradient of J(w) w.r.t. weight wⱼ: ∂J/∂wⱼ = (1/N) Σᵢ (p̂ᵢ − yᵢ) · xᵢⱼ Beautifully simple: error (prediction − label) × feature value, averaged over all samples. Same form as linear regression gradient — the sigmoid derivative cancels out nicely!

But how do we arrive at this beautifully simple result? It takes a chain of three derivatives — through the loss, through the sigmoid, and into the linear model. Let's walk through every step.

Full Derivation: ∂J/∂wⱼ Step by Step

We need to differentiate the cost function with respect to a single weight wⱼ. This requires the chain rule because the cost depends on p̂, which depends on z, which depends on wⱼ.

The Chain Rule Roadmap
CHAIN RULE DECOMPOSITION ∂J/∂wⱼ = ∂J/∂p̂ × ∂p̂/∂z × ∂z/∂wⱼ Loss → Sigmoid Sigmoid → Linear Linear → Weight
Step 1 of 3 — Derivative of Cost w.r.t. p̂ (the prediction)
START: Cost for a single sample (drop the 1/N average for now, add back at the end) J = −[ y · log(p̂) + (1 − y) · log(1 − p̂) ] Differentiate w.r.t. p̂ using d/dx[log(x)] = 1/x: ∂J/∂p̂ = −[ y · (1/p̂) + (1 − y) · (−1/(1 − p̂)) ] Simplify (distribute the negative sign): ∂J/∂p̂ = −y/p̂ + (1 − y)/(1 − p̂)
Step 2 of 3 — Derivative of σ(z) w.r.t. z (the sigmoid derivative)
START: p̂ = σ(z) = 1 / (1 + e⁻ᶻ) Rewrite as: p̂ = (1 + e⁻ᶻ)⁻¹ and use the power rule + chain rule Apply power rule: d/dz[(1+e⁻ᶻ)⁻¹] = −1·(1+e⁻ᶻ)⁻² · d/dz[1+e⁻ᶻ] ∂p̂/∂z = −(1+e⁻ᶻ)⁻² · (−e⁻ᶻ) = e⁻ᶻ / (1+e⁻ᶻ)² The elegant trick — rewrite in terms of σ(z) itself: Since σ(z) = 1/(1+e⁻ᶻ), we know that 1−σ(z) = e⁻ᶻ/(1+e⁻ᶻ). Therefore: e⁻ᶻ / (1+e⁻ᶻ)² = [1/(1+e⁻ᶻ)] · [e⁻ᶻ/(1+e⁻ᶻ)] = σ(z) · (1 − σ(z)) Result — the famous sigmoid derivative: ∂p̂/∂z = p̂ · (1 − p̂)
Why this is elegant: The sigmoid's derivative can be expressed entirely in terms of its own output. No need to recompute e⁻ᶻ during backpropagation — just use the forward-pass value p̂ that you already computed. This makes training efficient.
Step 3 of 3 — Derivative of z w.r.t. wⱼ (the trivial step)
Since z = w₁x₁ + w₂x₂ + ... + wⱼxⱼ + ... + wₙxₙ + b: ∂z/∂wⱼ = xⱼ All other terms vanish — only the term containing wⱼ survives, leaving just its coefficient xⱼ.

Putting It All Together — The Cancellation

Now we multiply the three pieces from the chain rule. Watch how the intermediate terms cancel beautifully.

Chain Rule Assembly & Cancellation
ASSEMBLE: Plug in all three derivatives ∂J/∂wⱼ = [ −y/p̂ + (1−y)/(1−p̂) ] × [ p̂(1−p̂) ] × [ xⱼ ] DISTRIBUTE p̂(1−p̂) into the first bracket: = [ −y/p̂ · p̂(1−p̂) + (1−y)/(1−p̂) · p̂(1−p̂) ] · xⱼ CANCEL — this is where the magic happens: First term: −y/p̂ · p̂(1−p̂) → p̂ cancels → −y(1−p̂) Second term: (1−y)/(1−p̂) · p̂(1−p̂) → (1−p̂) cancels → (1−y)p̂ COMBINE the two terms: = [ −y(1−p̂) + (1−y)p̂ ] · xⱼ = [ −y + yp̂ + p̂ − yp̂ ] · xⱼ = (p̂ − y) · xⱼ FINAL (average over N samples): ∂J/∂wⱼ = (1/N) Σᵢ (p̂ᵢ − yᵢ) · xᵢⱼ ∎
Why the yp̂ terms cancel: In the expansion −y + yp̂ + p̂ − yp̂, the +yp̂ and −yp̂ cancel each other exactly, leaving just p̂ − y. This is why the logistic regression gradient has the same elegant form as linear regression — the sigmoid derivative absorbs all the complexity.

Gradient for the Bias Term

The bias b follows the same chain rule, except ∂z/∂b = 1 (since z = w·x + b). So:

Bias Gradient
∂J/∂b = (1/N) Σᵢ (p̂ᵢ − yᵢ) Same as weight gradient, but without the xᵢⱼ multiplier
AdTech note: Each training example is one ad impression. The gradient tells us: "For impressions where we over-predicted clicks (p̂ > y), reduce weights for the active features; for under-predictions, increase them."

Regularization — L1 vs L2

With many features (AdTech models can have millions of sparse features), the model risks overfitting — memorizing noise instead of learning patterns. Regularization adds a penalty for large weights to the cost function.

Regularized Cost Functions
BASE COST (NO REGULARIZATION) J(w) = −(1/N) Σ [ y log(p̂) + (1−y) log(1−p̂) ] L2 REGULARIZATION (RIDGE) J_L2(w) = J(w) + λ · Σⱼ wⱼ² Penalizes large weights quadratically → shrinks all weights toward zero but rarely to exactly zero L1 REGULARIZATION (LASSO) J_L1(w) = J(w) + λ · Σⱼ |wⱼ| Penalizes weights linearly → drives many weights to exactly zero (automatic feature selection!)
Geometric Intuition: Why L1 produces sparse weights
L2 Constraint (circle) w₁ w₂ J* (unconstrained) Solution Meets on curve → both w₁, w₂ ≠ 0 L1 Constraint (diamond) w₁ w₂ J* (unconstrained) Solution Meets at corner → w₂ = 0 (sparse!)

The diamond's sharp corners mean the cost function's contours are more likely to first touch the constraint boundary at an axis — setting one or more weights to exactly zero. This is why L1 performs automatic feature selection.

Gradient Descent with Regularization — Updated Rules
L2 Gradient Update wⱼ ← wⱼ − α(∂J/∂wⱼ + 2λwⱼ) L1 Gradient Update wⱼ ← wⱼ − α(∂J/∂wⱼ + λ·sign(wⱼ)) λ (lambda) controls the strength of regularization. λ = 0 → no regularization | λ → ∞ → all weights crushed to zero | Tune via cross-validation.

When to Use Which?

Scenario Recommended Reasoning
Many features, most are relevant L2 (Ridge) Keeps all features but shrinks large weights; stable with correlated features
Many features, only few matter L1 (Lasso) Drives irrelevant feature weights to zero; built-in feature selection
AdTech with millions of sparse features L1 or Elastic Net L1 eliminates unused features; Elastic Net (L1+L2) handles correlated features better
Small dataset, risk of overfitting L2 (Ridge) More stable; doesn't eliminate features when data is scarce
Need model interpretability L1 (Lasso) Sparse model = easier to explain which features drive predictions

Optimizer Variants — SGD, Mini-Batch, Adam

"Gradient Descent" has several flavors that differ in how much data is used per update and how the learning rate is adapted.

The Spectrum: Batch Size per Update
SGD 1 sample per update Mini-Batch 32-512 samples per update Batch GD ALL N samples per update ← Noisier, Faster iterations Smoother, Slower iterations →
Convergence Paths Compared
min SGD Mini-Batch Batch GD SGD bounces around but often finds flatter minima → better generalization

Adam: Adaptive Moment Estimation

Adam combines two ideas: momentum (use running average of gradients to smooth updates) and RMSProp (adapt learning rate per parameter based on gradient magnitude). It's the most popular optimizer in deep learning and works very well as a default.

Adam's Three Key Components
① MOMENTUM Running mean of gradients m ← β₁·m + (1−β₁)·g Smooths out noise by averaging past gradient directions β₁ = 0.9 (typical) Like a "rolling ball" effect ② ADAPTIVE LR Running mean of squared grads v ← β₂·v + (1−β₂)·g² Large past gradients → smaller step now Small past gradients → larger step now β₂ = 0.999 (typical) ③ UPDATE Bias-corrected combination m̂ = m / (1−β₁ᵗ) v̂ = v / (1−β₂ᵗ) w ← w − α·m̂/(√v̂+ε) Per-parameter LR that adapts automatically α = 0.001 (typical)

When to Use Which Optimizer?

Optimizer Best For Pros Cons
SGD Simple models, convex problems (logistic regression), when generalization matters most Simple, low memory, can find flat minima (better generalization), well-understood convergence Needs careful LR tuning, slow convergence, noisy updates
Mini-Batch SGD Production systems, GPU-accelerated training, most practical scenarios Good balance of speed & stability, leverages hardware parallelism, vectorized operations Batch size is another hyperparameter to tune
SGD + Momentum Deep networks with consistent gradient directions, image classification (CNNs) Accelerates through flat regions, dampens oscillations One more hyperparameter (β)
Adam Default choice for deep learning, sparse gradients, NLP, transformers, complex landscapes Per-parameter adaptive LR, works well out-of-the-box, fast early convergence Higher memory (stores m & v), may generalize worse than tuned SGD
AdamW Modern deep learning (transformers, LLMs) Fixes weight decay behavior in Adam, better regularization Slightly more complex implementation
AdTech recommendation: For logistic regression at scale, Mini-Batch SGD with L1 regularization is the standard. Batch sizes of 256-1024 work well. For more complex models (deep CTR prediction), Adam or AdamW are the default starting points. FTRL-Proximal (Follow-the-Regularized-Leader) is another popular optimizer specifically designed for online sparse logistic regression in AdTech systems.

Quick Reference Cheat Sheet

Complete Logistic Regression Pipeline
Features x₁...xₙ Linear z = w·x + b Sigmoid p̂ = σ(z) BCE Loss J(w) Optimize GD/SGD/Adam + L1/L2 regularization update weights w

Key Equations Summary

Model: p̂ = σ(w·x + b)
Loss: BCE = −[y log p̂ + (1−y) log(1−p̂)]
Gradient: ∂J/∂wⱼ = (1/N) Σ(p̂−y)xⱼ
Update: w ← w − α · ∇J(w)

Decision Checklist

Many sparse features? → L1
Correlated features? → L2 or Elastic Net
Simple convex model? → SGD/Mini-Batch
Deep model / unsure? → Adam
Huge output space? → NCE loss

Python Implementation

Here's a complete, production-style implementation of logistic regression with multiple optimization variants, regularization options, and a realistic AdTech example.

import numpy as np
import pandas as pd
from typing import Literal, Tuple, Optional

class LogisticRegression:
    """
    Logistic Regression with multiple optimization variants.

    Supports:
    - Optimizers: BGD, SGD, Mini-Batch SGD, SGD+Momentum, Adam
    - Regularization: None, L1, L2, Elastic Net

    Example:
        model = LogisticRegression(optimizer='adam', reg_type='l2')
        model.fit(X_train, y_train)
        y_pred = model.predict(X_test)
    """

    def __init__(
        self,
        learning_rate: float = 0.01,
        n_iterations: int = 1000,
        optimizer: Literal['bgd', 'sgd', 'minibatch', 'momentum', 'adam'] = 'bgd',
        batch_size: int = 32,
        reg_type: Literal['none', 'l1', 'l2', 'elasticnet'] = 'none',
        reg_lambda: float = 0.01,
        l1_ratio: float = 0.5,
        momentum_beta: float = 0.9,
        adam_beta1: float = 0.9,
        adam_beta2: float = 0.999,
        adam_epsilon: float = 1e-8,
        verbose: bool = False
    ):
        self.learning_rate = learning_rate
        self.n_iterations = n_iterations
        self.optimizer = optimizer
        self.batch_size = batch_size
        self.reg_type = reg_type
        self.reg_lambda = reg_lambda
        self.l1_ratio = l1_ratio
        self.momentum_beta = momentum_beta
        self.adam_beta1 = adam_beta1
        self.adam_beta2 = adam_beta2
        self.adam_epsilon = adam_epsilon
        self.verbose = verbose

        # Initialize weights and biases
        self.w = None
        self.b = 0.0
        self.loss_history = []

        # For momentum and Adam
        self.velocity = None  # for momentum
        self.m = None  # for Adam (1st moment)
        self.v = None  # for Adam (2nd moment)

    def _sigmoid(self, z: np.ndarray) -> np.ndarray:
        """Sigmoid activation: 1 / (1 + exp(-z))"""
        # Clip to prevent overflow
        z = np.clip(z, -500, 500)
        return 1 / (1 + np.exp(-z))

    def _compute_loss(self, X: np.ndarray, y: np.ndarray) -> float:
        """
        Compute binary cross-entropy loss + regularization penalty.

        J(w) = -(1/N) * Σ[y*log(p̂) + (1-y)*log(1-p̂)] + reg_penalty
        """
        m = X.shape[0]
        z = X @ self.w + self.b
        p_hat = self._sigmoid(z)

        # Clip to prevent log(0)
        p_hat = np.clip(p_hat, 1e-15, 1 - 1e-15)

        # BCE loss
        bce_loss = -(1/m) * np.sum(
            y * np.log(p_hat) + (1 - y) * np.log(1 - p_hat)
        )

        # Regularization penalty
        if self.reg_type == 'l1':
            reg_penalty = self.reg_lambda * np.sum(np.abs(self.w))
        elif self.reg_type == 'l2':
            reg_penalty = self.reg_lambda * np.sum(self.w ** 2)
        elif self.reg_type == 'elasticnet':
            reg_penalty = self.reg_lambda * (
                self.l1_ratio * np.sum(np.abs(self.w)) +
                (1 - self.l1_ratio) * np.sum(self.w ** 2)
            )
        else:
            reg_penalty = 0.0

        return bce_loss + reg_penalty

    def _compute_gradients(self, X: np.ndarray, y: np.ndarray) -> Tuple[np.ndarray, float]:
        """
        Compute gradient of loss w.r.t. weights and bias.

        ∂J/∂w_j = (1/N) * Σ(p̂_i - y_i) * x_{i,j} + reg_gradient
        ∂J/∂b = (1/N) * Σ(p̂_i - y_i)
        """
        m = X.shape[0]
        z = X @ self.w + self.b
        p_hat = self._sigmoid(z)

        error = p_hat - y
        grad_w = (1/m) * (X.T @ error)
        grad_b = (1/m) * np.sum(error)

        return grad_w, grad_b

    def _apply_regularization_gradient(self, grad_w: np.ndarray) -> np.ndarray:
        """Add regularization term to gradient."""
        if self.reg_type == 'l1':
            grad_w += self.reg_lambda * np.sign(self.w)
        elif self.reg_type == 'l2':
            grad_w += 2 * self.reg_lambda * self.w
        elif self.reg_type == 'elasticnet':
            grad_w += self.reg_lambda * (
                self.l1_ratio * np.sign(self.w) +
                2 * (1 - self.l1_ratio) * self.w
            )
        return grad_w

    def _update_bgd(self, X: np.ndarray, y: np.ndarray) -> None:
        """Batch gradient descent: update on entire dataset."""
        grad_w, grad_b = self._compute_gradients(X, y)
        grad_w = self._apply_regularization_gradient(grad_w)

        self.w -= self.learning_rate * grad_w
        self.b -= self.learning_rate * grad_b

    def _update_sgd(self, X: np.ndarray, y: np.ndarray) -> None:
        """Stochastic gradient descent: update on one sample at a time."""
        indices = np.random.permutation(X.shape[0])
        for i in indices:
            x_i = X[i:i+1]
            y_i = y[i:i+1]

            grad_w, grad_b = self._compute_gradients(x_i, y_i)
            grad_w = self._apply_regularization_gradient(grad_w)

            self.w -= self.learning_rate * grad_w
            self.b -= self.learning_rate * grad_b

    def _update_minibatch(self, X: np.ndarray, y: np.ndarray) -> None:
        """Mini-batch SGD: update on batch_size samples."""
        indices = np.random.permutation(X.shape[0])
        for start_idx in range(0, X.shape[0], self.batch_size):
            end_idx = min(start_idx + self.batch_size, X.shape[0])
            batch_indices = indices[start_idx:end_idx]

            X_batch = X[batch_indices]
            y_batch = y[batch_indices]

            grad_w, grad_b = self._compute_gradients(X_batch, y_batch)
            grad_w = self._apply_regularization_gradient(grad_w)

            self.w -= self.learning_rate * grad_w
            self.b -= self.learning_rate * grad_b

    def _update_momentum(self, X: np.ndarray, y: np.ndarray) -> None:
        """SGD with momentum: accumulate velocity direction."""
        if self.velocity is None:
            self.velocity = np.zeros_like(self.w)

        indices = np.random.permutation(X.shape[0])
        for i in indices:
            x_i = X[i:i+1]
            y_i = y[i:i+1]

            grad_w, grad_b = self._compute_gradients(x_i, y_i)
            grad_w = self._apply_regularization_gradient(grad_w)

            # Accumulate velocity
            self.velocity = self.momentum_beta * self.velocity - self.learning_rate * grad_w
            self.w += self.velocity
            self.b -= self.learning_rate * grad_b

    def _update_adam(self, X: np.ndarray, y: np.ndarray) -> None:
        """Adam optimizer: adaptive per-parameter learning rates."""
        if self.m is None:
            self.m = np.zeros_like(self.w)
            self.v = np.zeros_like(self.w)

        indices = np.random.permutation(X.shape[0])
        for t, i in enumerate(indices, 1):
            x_i = X[i:i+1]
            y_i = y[i:i+1]

            grad_w, grad_b = self._compute_gradients(x_i, y_i)
            grad_w = self._apply_regularization_gradient(grad_w)

            # Update biased first moment estimate (mean)
            self.m = self.adam_beta1 * self.m + (1 - self.adam_beta1) * grad_w

            # Update biased second moment estimate (variance)
            self.v = self.adam_beta2 * self.v + (1 - self.adam_beta2) * (grad_w ** 2)

            # Bias correction
            m_hat = self.m / (1 - self.adam_beta1 ** t)
            v_hat = self.v / (1 - self.adam_beta2 ** t)

            # Update parameters
            self.w -= self.learning_rate * m_hat / (np.sqrt(v_hat) + self.adam_epsilon)
            self.b -= self.learning_rate * grad_b

    def fit(self, X: np.ndarray, y: np.ndarray) -> None:
        """
        Train the logistic regression model.

        Args:
            X: (n_samples, n_features) feature matrix
            y: (n_samples,) binary labels {0, 1}
        """
        # Initialize weights
        if self.w is None:
            self.w = np.zeros(X.shape[1])

        # Training loop
        for iteration in range(self.n_iterations):
            # Update weights based on chosen optimizer
            if self.optimizer == 'bgd':
                self._update_bgd(X, y)
            elif self.optimizer == 'sgd':
                self._update_sgd(X, y)
            elif self.optimizer == 'minibatch':
                self._update_minibatch(X, y)
            elif self.optimizer == 'momentum':
                self._update_momentum(X, y)
            elif self.optimizer == 'adam':
                self._update_adam(X, y)

            # Record loss
            loss = self._compute_loss(X, y)
            self.loss_history.append(loss)

            if self.verbose and (iteration + 1) % max(1, self.n_iterations // 10) == 0:
                print(f"Iteration {iteration + 1}/{self.n_iterations}, Loss: {loss:.6f}")

    def predict_proba(self, X: np.ndarray) -> np.ndarray:
        """Return predicted probabilities P(y=1 | X)."""
        z = X @ self.w + self.b
        return self._sigmoid(z)

    def predict(self, X: np.ndarray, threshold: float = 0.5) -> np.ndarray:
        """Return binary predictions {0, 1}."""
        return (self.predict_proba(X) >= threshold).astype(int)


# ============ ADTECH EXAMPLE: PREDICTING AD CLICKS ============

# Generate synthetic AdTech click data
np.random.seed(42)
n_samples = 10000

# Features: user_age, hour_of_day, is_mobile, ad_relevance, user_past_ctr, page_score, ad_position
X = pd.DataFrame({
    'user_age_bucket': np.random.randint(1, 8, n_samples),
    'hour_of_day': np.random.randint(0, 24, n_samples),
    'is_mobile': np.random.binomial(1, 0.4, n_samples),
    'ad_relevance': np.random.rand(n_samples),
    'user_past_ctr': np.random.rand(n_samples) * 0.05,
    'page_context_score': np.random.rand(n_samples),
    'ad_position': np.random.randint(1, 6, n_samples),
})

# Normalize features
X = (X - X.mean()) / X.std()
X_array = X.values

# Generate labels with signal from features
true_weights = np.array([0.3, 0.1, -0.2, 0.5, 0.4, 0.2, -0.15])
logits = X_array @ true_weights + np.random.normal(0, 0.3, n_samples)
click_probs = 1 / (1 + np.exp(-logits))
y = (click_probs > np.random.rand(n_samples)).astype(int)

print(f"Dataset: {n_samples} samples, {X.shape[1]} features")
print(f"Click rate: {y.mean():.2%}")
print()

# Split into train/test
split = int(0.8 * n_samples)
X_train, X_test = X_array[:split], X_array[split:]
y_train, y_test = y[:split], y[split:]

# Compare all optimizers with L2 regularization
optimizers = ['bgd', 'sgd', 'minibatch', 'momentum', 'adam']
results = {}

print("=" * 70)
print("COMPARING OPTIMIZERS (L2 Regularization, λ=0.01)")
print("=" * 70)

for opt in optimizers:
    model = LogisticRegression(
        optimizer=opt,
        learning_rate=0.01,
        n_iterations=500,
        batch_size=64,
        reg_type='l2',
        reg_lambda=0.01,
        verbose=False
    )

    model.fit(X_train, y_train)

    # Compute metrics
    train_proba = model.predict_proba(X_train)
    test_proba = model.predict_proba(X_test)

    train_pred = (train_proba >= 0.5).astype(int)
    test_pred = (test_proba >= 0.5).astype(int)

    train_acc = (train_pred == y_train).mean()
    test_acc = (test_pred == y_test).mean()
    final_loss = model.loss_history[-1]

    results[opt] = {
        'train_acc': train_acc,
        'test_acc': test_acc,
        'final_loss': final_loss,
        'convergence': min(model.loss_history),
        'model': model
    }

    print(f"{opt.upper():12} | Loss: {final_loss:.6f} | "
          f"Train Acc: {train_acc:.4f} | Test Acc: {test_acc:.4f}")

print()
print("=" * 70)
print("COMPARING REGULARIZATION TYPES (Mini-Batch SGD)")
print("=" * 70)

reg_types = ['none', 'l1', 'l2', 'elasticnet']
for reg in reg_types:
    model = LogisticRegression(
        optimizer='minibatch',
        learning_rate=0.01,
        n_iterations=500,
        batch_size=64,
        reg_type=reg,
        reg_lambda=0.01 if reg != 'none' else 0,
        l1_ratio=0.5,  # for elastic net
        verbose=False
    )

    model.fit(X_train, y_train)

    test_pred = model.predict(X_test)
    test_acc = (test_pred == y_test).mean()
    final_loss = model.loss_history[-1]
    sparsity = (model.w == 0).mean()

    print(f"{reg.upper():12} | Loss: {final_loss:.6f} | "
          f"Test Acc: {test_acc:.4f} | Sparsity: {sparsity:.2%}")

print()
print("=" * 70)
print("BEST MODEL WEIGHTS (Adam, L2)")
print("=" * 70)
best_model = results['adam']['model']
feature_names = list(X.columns)
for fname, weight in zip(feature_names, best_model.w):
    print(f"{fname:20} : {weight:+.6f}")
End of Logistic Regression