Machine LearningTrees and Ensembles › Day 165

Day 165: XGBoost and LightGBM in Practice

Day 165 of 365 — XGBoost and LightGBM in Practice

Master the modern industrial gradient boosting ecosystem -- XGBoost, LightGBM, CatBoost, and HistGradientBoosting: why exact second-order Taylor expansion unifies loss optimization and tree regularization, how histogram-based binning achieves an 8x memory reduction and 10x training speedup, how leaf-wise growth differs from level-wise growth, how GOSS and EFB optimize million-row datasets, and how modern tree libraries handle missing values and categoricals natively.

Course
Machine Learning
Category
Trees and Ensembles
Reading time
≈ 50 min
Practical time
≈ 60 min
Lesson duration
1h 50m
Last verified
2026-08-29

Hands-on lab for this lesson

Lab files on GitHub: https://github.com/ai-roadmap-365/ai-roadmap-365.github.io/tree/main/labs/sections/machine-learning/day-165-xgboost-and-lightgbm-in-practice

  1. Get the hands-on files. Clone the labs repository once (you can reuse this clone for every lesson). This works on macOS, Linux, and Windows (PowerShell or WSL):
    git clone https://github.com/ai-roadmap-365/ai-roadmap-365.github.io.git
    cd ai-roadmap-365.github.io
  2. Open this lesson's lab. Move into the directory for this specific day. Every lab lives at the same predictable path — section / subsection / week / day:
    cd labs/sections/machine-learning/day-165-xgboost-and-lightgbm-in-practice
  3. Read the lab guide. Open `README.md` in that directory. It lists the exact commands, what each does, the expected output, and how to check your work — read it before running anything.
  4. Run it and check your work. Follow the README's "How to run" section: run the example first to see the finished result, then complete the numbered exercises in `starter/`, then run the tests. The tests pass (exit 0) only when your work is correct.
    bash tests/run_tests.sh   # or the test command named in the lab README

You can also open the lab as a local page (works offline, shows the file tree and expected output).

Learning objectives

By the end of this lesson you will be able to:

Prerequisites

Why this matters

In Day 164, we built Gradient Boosting from scratch, deriving functional gradient descent, pseudo-residuals, and Newton-Raphson leaf updates.

While classic gradient boosting is mathematically beautiful, training traditional decision trees on continuous features requires sorting every column at every node: O(D * N * log N). On datasets with 500,000 rows and 100 features, training 500 trees in scikit-learn’s standard GradientBoostingClassifier can take 45 minutes and consume gigabytes of RAM.

In the mid-2010s, a revolution occurred in competitive tabular machine learning with the release of three hyper-optimized libraries:

  1. XGBoost (Tianqi Chen & Carlos Guestrin, 2016)
  2. LightGBM (Guolin Ke et al., Microsoft Research, 2017)
  3. CatBoost (Yandex, 2017)

These libraries did not merely optimize C++ code; they introduced foundational algorithmic breakthroughs: exact second-order Taylor objective expansion, uint8 histogram-based feature binning, leaf-wise (best-first) tree growth, Gradient-based One-Side Sampling (GOSS), Exclusive Feature Bundling (EFB), and native missing value and categorical support.

Today, over 80% of winning tabular Kaggle solutions and mission-critical production pipelines at Uber, Airbnb, Meta, and financial institutions rely on these gradient boosting engines.


The idea in plain language

Imagine you are sorting 10,000 physical packages by their exact weight measured to 6 decimal places (e.g. 12.483921 kg):

By compressing continuous data into 256 discrete bins, feature values fit into a single byte (uint8), memory consumption drops by 8x, and split evaluations execute at hardware-accelerated speeds.


Historical background

In 2014, Tianqi Chen developed XGBoost (Extreme Gradient Boosting) as a research project at the University of Washington. Chen integrated exact second-order Taylor approximations, cache-aware out-of-core block data structures, and sparsity-aware split routing. XGBoost quickly went viral in competitive data science, sweeping the 2015 Kaggle Higgs Boson and KDDCup challenges.

In 2017, Microsoft Research published LightGBM. Microsoft identified the primary computational bottlenecks of XGBoost and introduced histogram-based binning, leaf-wise tree growth, GOSS, and EFB. LightGBM trained 10 to 15 times faster than early XGBoost while using a fraction of the RAM.

In response, XGBoost added its own ultra-fast tree_method='hist', and scikit-learn developed HistGradientBoostingClassifier.

Concurrently in 2017, Yandex introduced CatBoost, revolutionizing categorical feature processing with Ordered Target Statistics and Oblivious (symmetric) decision trees.


What it is — and what it is not

To architect production ML pipelines with these libraries, let us contrast their core properties:

What it IS:

What it is NOT:


Why it was created and what problems it solves

Modern gradient boosting engines solve five massive engineering bottlenecks:

  1. Computational Speed on Million-Row Datasets: Histogram binning reduces training time from hours to seconds.
  2. Eliminates Manual Missing Value Imputation: Learns default split directions for NaN values directly during training.
  3. Eliminates One-Hot Dimensionality Blowup: Natively evaluates categorical splits without expanding columns.
  4. Hardware-Optimized Parallelism: Uses SIMD/AVX2 vector instructions and multi-threaded CPU/GPU acceleration.
  5. Exact Loss Regularization: Penalizes leaf weights (lambda) and leaf count (gamma), smoothing out noisy predictions.

How it works

Let us formulate the mathematical innovations that distinguish XGBoost and LightGBM.

1. XGBoost Second-Order Taylor Objective

Given dataset D = { (x_i, y_i) } and loss l(y_i, hat{y}_i), at step t, the objective function with explicit regularization is:

L^{(t)} = sum_{i=1}^N l(y_i, hat{y}_i^{(t-1)} + f_t(x_i)) + Omega(f_t)

Where tree regularization Omega(f_t) is:

Omega(f_t) = gamma * T + (1/2) * lambda * sum_{j=1}^T w_j^2 + alpha * sum_{j=1}^T |w_j|

Where T is the number of terminal leaf nodes, w_j is the weight of leaf j, lambda is L2 ridge penalty, alpha is L1 lasso penalty, and gamma is the complexity penalty for creating a new leaf.

Taking a second-order Taylor expansion around hat{y}^{(t-1)}:

l(y_i, hat{y}_i^{(t-1)} + f_t(x_i)) approx l(y_i, hat{y}_i^{(t-1)}) + g_i * f_t(x_i) + (1/2) * h_i * f_t^2(x_i)

Where:

Removing constant terms, the simplified objective at step t is:

tilde{L}^{(t)} = sum_{j=1}^T [ ( sum_{i in I_j} g_i ) * w_j + (1/2) * ( sum_{i in I_j} h_i + lambda ) * w_j^2 ] + gamma * T

Let G_j = sum_{i in I_j} g_i and H_j = sum_{i in I_j} h_i.

Setting the derivative with respect to w_j to zero yields the optimal leaf weight:

w_j^* = - ( G_j / (H_j + lambda) )

Substituting w_j^* back gives the optimal objective value (structure score):

tilde{L}^*(q) = - (1/2) * sum_{j=1}^T ( G_j^2 / (H_j + lambda) ) + gamma * T


2. The XGBoost Split Gain Formula

When deciding whether to split leaf node I into left child I_L and right child I_R, XGBoost calculates the exact reduction in loss:

Gain = (1/2) * [ ( G_L^2 / (H_L + lambda) ) + ( G_R^2 / (H_R + lambda) ) - ( (G_L + G_R)^2 / (H_L + H_R + lambda) ) ] - gamma


3. LightGBM Histogram Binning and Leaf-Wise Growth

A. Histogram Binning

Continuous float64 values (8 bytes) are binned into 256 discrete bins (uint8, 1 byte):

bin_i = digitize(x_i, quantiles)

Building a histogram of gradients G and Hessians H across 256 bins requires a single linear scan O(N). Finding the best split threshold then takes O(256) constant operations.

B. Leaf-Wise (Best-First) vs Level-Wise (Depth-First) Growth


4. LightGBM GOSS and EFB

  1. GOSS (Gradient-based One-Side Sampling):
    • Keeps all top a * 100% instances with large gradients (under-fitted data).
    • Randomly samples b * 100% instances from the remaining small-gradient data.
    • Multiplies small-gradient instances by (1 - a) / b to preserve the true gradient expectation.
  2. EFB (Exclusive Feature Bundling):
    • Groups mutually exclusive sparse features (e.g. one-hot encoded columns that are rarely non-zero at the same time) into a single dense feature bundle.

An everyday analogy

Think of the evolution of gradient boosting as mail sorting at a postal hub:

  1. Classic Gradient Boosting (Hand Sorting): The postal worker reads the exact 9-digit ZIP code on every envelope, constantly re-sorting all 1,000,000 envelopes alphabetically and numerically. It works, but takes 12 hours.
  2. XGBoost (2nd-Order Taylor + Smart Routing): The postal worker uses a laser scanner that calculates both the destination distance and package weight simultaneously, automatically routing damaged/missing labels to a default conveyor belt.
  3. LightGBM (Histogram Bins + GOSS): The hub installs 256 color-coded collection bins. Envelopes are tossed into bins once upon arrival (uint8). Furthermore, routine junk mail (small gradients) is sampled at a 10% rate, while express certified mail (large gradients) is 100% inspected. Sorting finishes in 10 minutes.

Examples in practice

Let us visualize the architectural comparison between XGBoost and LightGBM:

Architecture comparison diagram contrasting Level-Wise growth vs Leaf-Wise growth, and Exact float64 sorting vs uint8 Histogram binning.

The diagram illustrates how second-order optimization and histogram binning revolutionize tree construction.

Below is the flow of LightGBM’s GOSS and EFB acceleration mechanisms:

Animated flow chart illustrating how GOSS filters small-gradient samples and EFB merges sparse orthogonal features.

Let us examine real Python code using HistGradientBoostingClassifier with missing values and early stopping:

import numpy as np
from sklearn.datasets import make_classification
from sklearn.ensemble import HistGradientBoostingClassifier
from sklearn.model_selection import train_test_split
from sklearn.metrics import accuracy_score, roc_auc_score

# 1. Generate Tabular Dataset with Missing Values
X, y = make_classification(
    n_samples=2000, n_features=25, n_informative=15, random_state=42
)

# Inject 15% random NaNs (Missing data)
rng = np.random.default_rng(42)
mask = rng.random(X.shape) < 0.15
X_missing = X.copy()
X_missing[mask] = np.nan

X_train, X_test, y_train, y_test = train_test_split(
    X_missing, y, test_size=0.25, stratify=y, random_state=42
)

# 2. Train Histogram Gradient Booster (Native NaN Support!)
hgb = HistGradientBoostingClassifier(
    max_iter=150,
    learning_rate=0.08,
    max_leaf_nodes=31,
    l2_regularization=1.5,
    early_stopping=True,
    n_iter_no_change=10,
    random_state=42
)
hgb.fit(X_train, y_train)

# 3. Evaluate Predictions
test_preds = hgb.predict(X_test)
test_probs = hgb.predict_proba(X_test)[:, 1]

print("=== Histogram Gradient Boosting Benchmark ===")
print(f"Iterations Trained:     {hgb.n_iter_} (Stopped early)")
print(f"Test Accuracy:          {accuracy_score(y_test, test_preds) * 100:.2f}%")
print(f"Test ROC-AUC:           {roc_auc_score(y_test, test_probs):.4f}")

Implications: security, privacy, performance, scalability, and cost

DimensionCharacteristicPractical Implication
Training Speed & Memoryuint8 binning + O(256) histograms.8x memory reduction; 10x–15x faster training than classic GradientBoostingClassifier.
Missing Value ExploitsLearned default branches for NaN.Attackers could deliberately omit features to trigger specific default leaf paths; monitor missingness distributions in production.
Production SerializationCompilation via treelite / ONNX.Compiled boosted trees evaluate in < 50 microseconds on CPU with zero Python interpreter overhead.
Categorical SecurityTarget statistics leakage.Always compute out-of-fold target encodings to prevent test-set label contamination.

Alternatives: free, open source, and commercial

FrameworkCore StrengthsBest Used For
scikit-learn (HistGradientBoosting)Native Python/C, zero extra C++ dependenciesBaseline prototyping, small-to-medium datasets directly in scikit-learn pipelines.
XGBoostMature ecosystem, GPU acceleration, ranking/survival lossesKaggle competitions, production enterprise pipelines, multi-GPU training.
LightGBMFastest CPU training, lowest RAM, native categorical supportMassive datasets (10M+ rows), high-dimensional sparse tables.
CatBoostBest-in-class categorical encoding, robust default parametersHeavy categorical tabular data (e.g. e-commerce, click-through-rate prediction).

CharacteristicClassic Gradient BoostingXGBoostLightGBM
Objective Taylor Order1st-Order (Gradient only)2nd-Order (Gradient + Hessian)2nd-Order (Gradient + Hessian)
Split FindingExact sorting O(N log N)Histogram / ExactHistogram binning (256 uint8)
Tree GrowthLevel-wise (Depth-first)Level-wise / Depth-wiseLeaf-wise (Best-first)
Missing Values (NaN)Requires pre-imputationNative sparsity-aware routingNative sparsity-aware routing
Memory EfficiencyLow (float64 continuous)High (tree_method='hist')Ultra-High (uint8 bins + EFB)

When to use it — and when not to

When to USE Modern Boosted Engines (XGBoost / LightGBM / HistGB):

When NOT to use Modern Boosted Engines:


Knowledge check

  1. Second-Order Expansion: XGBoost expands loss to 2nd order using gradients g_i and Hessians h_i.
  2. Optimal Leaf Weight: w_j* = - sum(g_i) / (sum(h_i) + lambda).
  3. Histogram Binning: Discretizes float64 features into 256 uint8 bins, yielding an 8x memory reduction.
  4. Leaf-Wise Growth: LightGBM splits the single leaf with the highest gain across the entire tree.

Hands-on exercise

In this hands-on exercise, you will compute the exact XGBoost second-order split gain and test histogram discretization on a continuous feature.

import numpy as np

# Step 1: Compute XGBoost Second-Order Split Gain
def xgb_gain(g_l, h_l, g_r, h_r, reg_lambda=1.0, gamma=0.0):
    g_tot = g_l + g_r
    h_tot = h_l + h_r
    score_l = (g_l ** 2) / (h_l + reg_lambda)
    score_r = (g_r ** 2) / (h_r + reg_lambda)
    score_tot = (g_tot ** 2) / (h_tot + reg_lambda)
    return 0.5 * (score_l + score_r - score_tot) - gamma

# Test Case: Strong Gradient Separation vs Null Split
gain_split = xgb_gain(g_l=-10.0, h_l=10.0, g_r=10.0, h_r=10.0, reg_lambda=1.0, gamma=0.5)
gain_null = xgb_gain(g_l=0.0, h_l=5.0, g_r=0.0, h_r=5.0, reg_lambda=1.0, gamma=0.5)

print(f"Gain from strong separation: {gain_split:.4f}")
print(f"Gain from null separation:   {gain_null:.4f}")

# Step 2: Histogram Binning into 256 uint8 Bins
continuous_feature = np.random.exponential(scale=2.0, size=1000)
quantiles = np.linspace(0.0, 100.0, 257)[1:-1]
bin_edges = np.percentile(continuous_feature, quantiles)
binned_feature = np.digitize(continuous_feature, bin_edges).astype(np.uint8)

print(f"\nOriginal Array Memory: {continuous_feature.nbytes} bytes (float64)")
print(f"Binned Array Memory:   {binned_feature.nbytes} bytes (uint8)")
print(f"Memory Compression:    {continuous_feature.nbytes / binned_feature.nbytes:.1f}x reduction")

Expected output

Gain from strong separation: 8.5909
Gain from null separation:   -0.5000

Original Array Memory: 8000 bytes (float64)
Binned Array Memory:   1000 bytes (uint8)
Memory Compression:    8.0x reduction

Validate your work

  1. Verify that gain_null is negative when gamma > 0.0, confirming automatic pruning of uninformative splits.
  2. Confirm that binned_feature.dtype == np.uint8 with values bounded in [0, 255].
  3. Train HistGradientBoostingClassifier on a dataset with NaNs and verify test accuracy >= 90%.

Troubleshooting

Common mistakes

  1. Imputing NaNs Before Histogram Boosting: Unnecessary preprocessing; modern histogram boosting handles NaNs natively.
  2. One-Hot Encoding High-Cardinality Categoricals: Creates thousands of sparse columns; use native categorical integer handling instead.

Practice assignment

  1. Implement Sparsity-Aware Split Evaluation: Write a function that splits data with missing values by evaluating two options: (1) all NaNs go to the left child, (2) all NaNs go to the right child. Return the direction that maximizes xgb_gain.
  2. Compare Training Speeds: Generate a 50,000-row synthetic dataset and benchmark the training time of GradientBoostingClassifier (exact CART) vs HistGradientBoostingClassifier (histogram binning).

Extension challenge

Implement Gradient-based One-Side Sampling (GOSS) from Scratch:

  1. Sort training instances by absolute gradient magnitude |g_i|.
  2. Select top a = 20% instances with the largest gradients, and randomly sample b = 20% instances from the remaining 80%.
  3. Multiply the gradients of the sampled small instances by (1 - a) / b = 0.8 / 0.2 = 4.0 and verify that the estimated total gradient sum matches the full dataset gradient expectation.

Quiz

Q1. What is the key mathematical difference between standard Gradient Boosting (Friedman, 2001) and XGBoost (Chen & Guestrin, 2016)?

  1. Standard gradient boosting uses a first-order Taylor approximation (fitting trees to negative gradients); XGBoost uses a second-order Taylor expansion utilizing both first derivatives (g_i) and second derivatives (h_i / Hessian) directly in the split gain objective
  2. Standard gradient boosting uses neural networks; XGBoost uses decision trees
  3. Standard gradient boosting requires normalized features; XGBoost does not
  4. Standard gradient boosting is parallel; XGBoost is strictly single-threaded
Show answer

Answer: A. Standard gradient boosting uses a first-order Taylor approximation (fitting trees to negative gradients); XGBoost uses a second-order Taylor expansion utilizing both first derivatives (g_i) and second derivatives (h_i / Hessian) directly in the split gain objective

XGBoost expands the loss to second order L(y, y_hat + f) approx L + g*f + 0.5*h*f^2, allowing exact closed-form optimal leaf weights and split gain without heuristic line searches.

Q2. How does Histogram-Based Binning (introduced in LightGBM and adopted by XGBoost and scikit-learn HistGradientBoosting) achieve a 10x training speedup?

  1. It discretizes continuous 64-bit floating-point features into 256 integer bins (uint8); continuous sorting O(N log N) is replaced by constant-time O(256) histogram aggregation
  2. It removes 90% of the training dataset
  3. It replaces decision trees with logistic regressions
  4. It runs exclusively on quantum processors
Show answer

Answer: A. It discretizes continuous 64-bit floating-point features into 256 integer bins (uint8); continuous sorting O(N log N) is replaced by constant-time O(256) histogram aggregation

By grouping continuous values into 256 discrete bins, feature values can be stored in 1 byte (uint8) rather than 8 bytes, and building histograms for split evaluation takes O(n_bins) rather than O(N log N).

Q3. What is the difference between Level-Wise tree growth (XGBoost traditional / scikit-learn) and Leaf-Wise tree growth (LightGBM)?

  1. Level-wise grows the tree balanced level-by-level (depth-first); Leaf-wise chooses the single leaf that produces the maximum loss reduction (best-first) across all current leaves regardless of depth
  2. Level-wise is for regression; Leaf-wise is for classification
  3. Level-wise uses Gini; Leaf-wise uses Entropy
  4. Level-wise cannot handle categorical features
Show answer

Answer: A. Level-wise grows the tree balanced level-by-level (depth-first); Leaf-wise chooses the single leaf that produces the maximum loss reduction (best-first) across all current leaves regardless of depth

Leaf-wise (best-first) growth achieves lower loss with fewer leaves, but can produce deep, asymmetric branches that require max_depth or min_child_samples regularization to prevent overfitting.

Q4. What is Gradient-based One-Side Sampling (GOSS) in LightGBM?

  1. A technique that keeps all instances with large gradients (under-fitted samples) and randomly subsamples instances with small gradients (well-fitted samples), scaling the small-gradient samples to preserve the original gradient distribution
  2. A method that discards all negative targets
  3. A technique that trains trees on a single feature at a time
  4. A loss function for imbalanced datasets
Show answer

Answer: A. A technique that keeps all instances with large gradients (under-fitted samples) and randomly subsamples instances with small gradients (well-fitted samples), scaling the small-gradient samples to preserve the original gradient distribution

Samples with small gradients contribute little to the split search. GOSS discards most small-gradient instances while applying a constant multiplier to maintain unbiased gradient estimates.

Q5. How does modern XGBoost / LightGBM handle missing values (NaNs) in tabular data?

  1. Natively without imputation: during training, it evaluates whether sending missing values to the left child or right child yields higher split gain, and assigns a default direction for that feature
  2. It automatically deletes any row containing a NaN
  3. It replaces all NaNs with the column mean before training
  4. It throws a ValueError if NaNs are present
Show answer

Answer: A. Natively without imputation: during training, it evaluates whether sending missing values to the left child or right child yields higher split gain, and assigns a default direction for that feature

Sparsity-aware split finding learns an optimal default routing (left or right) for missing values at each split node, completely eliminating the need for mean or median imputation.

Q6. What is the architectural hallmark of CatBoost compared to XGBoost and LightGBM?

  1. It uses Oblivious (Symmetric) Decision Trees where all nodes at the same depth share the identical feature and split threshold, and Ordered Target Encoding to prevent target leakage
  2. It only runs on GPU
  3. It does not use decision trees
  4. It is restricted to binary classification
Show answer

Answer: A. It uses Oblivious (Symmetric) Decision Trees where all nodes at the same depth share the identical feature and split threshold, and Ordered Target Encoding to prevent target leakage

CatBoost uses symmetric oblivious trees that evaluate splits via SIMD vector operations, and Ordered Target Statistics to handle high-cardinality categoricals without overfitting.

Q7. What is the role of the lambda parameter in XGBoost second-order split score w_j* = - sum(g_i) / (sum(h_i) + lambda)?

  1. It acts as L2 ridge regularization on leaf weights, shrinking extreme leaf values towards zero and preventing tree explosion when Hessian sum(h_i) is small
  2. It controls the learning rate
  3. It sets the maximum tree depth
  4. It sets the percentage of bootstrap samples
Show answer

Answer: A. It acts as L2 ridge regularization on leaf weights, shrinking extreme leaf values towards zero and preventing tree explosion when Hessian sum(h_i) is small

Lambda penalizes large leaf weights in the objective function. When a leaf contains few samples or low Hessian curvature, lambda dominates the denominator and shrinks the leaf weight to near zero.

Q8. Why does scikit-learn HistGradientBoostingClassifier require NO One-Hot Encoding for categorical features?

  1. It natively partitions categorical integer categories into subsets based on their target statistics during split search, avoiding high-dimensional sparse matrix inflation
  2. It converts all strings to random numbers
  3. It ignores categorical columns during training
  4. It uses PCA on categoricals
Show answer

Answer: A. It natively partitions categorical integer categories into subsets based on their target statistics during split search, avoiding high-dimensional sparse matrix inflation

Modern histogram boosting natively sorts categorical categories by their target mean and searches for the optimal subset split directly in O(K log K) time, outperforming one-hot encoding.

Glossary

XGBoost (Extreme Gradient Boosting)
An open-source library implementing exact second-order Taylor boosting, sparsity-aware split finding, and explicit L1/L2 leaf regularization.
LightGBM
A Microsoft open-source gradient boosting framework optimized for high speed and low memory using histogram binning, leaf-wise growth, GOSS, and EFB.
CatBoost
A Yandex open-source gradient boosting library renowned for native categorical feature handling via ordered target statistics and symmetric oblivious trees.
Histogram-Based Binning
Discretizing continuous floating-point features into integer bins (typically 256 uint8 bins) to construct split histograms in O(n_bins) constant time.
Leaf-Wise (Best-First) Growth
A tree growth strategy that greedily splits the leaf node with the highest loss reduction across the entire tree, resulting in asymmetric depth.
Level-Wise (Depth-First) Growth
A tree growth strategy that splits all nodes at the current depth simultaneously before moving to the next level, producing balanced trees.
GOSS (Gradient-based One-Side Sampling)
A subsampling algorithm that retains all instances with large gradients and samples a small fraction of small-gradient instances to accelerate training.
EFB (Exclusive Feature Bundling)
Combining mutually exclusive sparse features (rarely non-zero simultaneously) into a single dense feature bundle to reduce feature dimension.
Oblivious (Symmetric) Tree
A decision tree where every node at the same tree depth uses the exact same feature and split threshold, enabling ultra-fast SIMD evaluation.
Sparsity-Aware Split Finding
An algorithm that evaluates splits on non-missing values and learns an optimal default branch direction (left or right) for missing values.

Sources and further reading


Kept in this browser, no account needed. Your progress page turns the whole record into one link you can bookmark or open on another device.