rml / Classical models

Classical models

rml/classical is the scikit-learn corner of rml: linear and logistic regression, nearest neighbors, k-means, PCA, naive Bayes, decision trees, random forests, gradient-boosted trees and a kernel SVM, fifteen estimator classes in ten headers. They take and return tensors, do their arithmetic in double, and are checked against scikit-learn on committed fixtures.

On this page
  1. The shared shape
  2. The models at a glance
  3. Linear models
  4. LinearRegression
  5. LogisticRegression
  6. Neighbors and clusters
  7. k-nearest neighbors
  8. KMeans
  9. PCA and naive Bayes
  10. Pca
  11. GaussianNb and MultinomialNb
  12. Trees
  13. Growing a CART tree
  14. Limits and feature subsets
  15. Random forests
  16. Gradient boosting
  17. Support vector machine
  18. Checked against scikit-learn
  19. Who uses it
  20. Tests

In short: every model has the same shape. Fit takes an f32 [n, d] tensor (and f32 targets or i32 labels), copies it into a double linalg::Matrix through one shared helper, and returns a Status; Predict (and PredictProba where it makes sense) converts the answer back to f32 or i32 tensors. Options are plain structs with scikit-learn's defaults where there is one. The solvers are the textbook ones, written out: Cholesky normal equations or gradient descent, Newton with a line search, Lloyd's algorithm after k-means++ seeding, an SVD, counting, a CART sweep, bootstrap forests, LightGBM-style histogram boosting and LIBSVM-style SMO. Randomness comes from seeded Philox generators and parallel loops from the thread pool, so results do not depend on the thread count. Nothing here trains through autograd, and the inference stack does not use it.

Each header links its concept page on /ml-models: linear regression and regularization, logistic regression, nearest neighbors, clustering, PCA, Bayes' rule, decision trees, random forests, gradient boosting and support vector machines.

The shared shape

How a classical model moves data between tensors and double matrices An f32 tensor x of shape n by d and a target tensor y go through internal::Features and internal::Targets or ClassLabels, which check dtype and shape and copy them into a double linalg::Matrix and std::vector. Fit runs the model's algorithm, using linalg for Cholesky and SVD, random for Philox generators and the core thread pool for ParallelFor, and stores the fitted state as f32 tensors or matrices. Predict copies the queries the same way, checking the fitted column count, and returns an f32 tensor through VectorTensor or an i32 tensor through ArgMaxRows or LabelTensor. Two users outside the module: retrieval's IvfIndex trains its lists with KMeans, and the time_series example fits LinearRegression. x: f32 [n, d] y: f32 [n] or i32 [n] internal::Features Targets / ClassLabels linalg::Matrix double, a copy Fit: the model's algorithm, returns Status state kept as f32 tensors (coef, centers) or matrices linalg, random, core Cholesky, SVD Philox, ParallelFor Predict / PredictProba / Transform Features(x, fitted d): the column count must match f32 or i32 out VectorTensor, ArgMaxRows Users outside the module: rml/retrieval's IvfIndex::Train runs KMeans (num_init 1, 50 iterations), and examples/time_series fits LinearRegression on lagged values.
One path for every model. The copy into double is deliberate: these are small-data algorithms where accuracy matters more than memory (matrix.h L9 describes the same convention for the decompositions).

The helpers in internal are the only shared code (common.h L12–31). Their checks are RML_CHECKs, so a wrong dtype or shape is a programmer error that aborts; data problems a caller cannot rule out in advance (singular systems, too few classes, k > n, non-convergence) come back as a Status.

HelperSignatureWhat it doesSource
Featureslinalg::Matrix(const Tensor& x), and (x, num_features)Checks f32, rank 2, at least one row, and copies into a Matrix. The two-argument form, used by every Predict, also checks the column count against the fitted data.common.cc L16–26
Targetsvector<double>(const Tensor& y, int64_t n)Rank-1 f32 of length n.L28–34
ClassLabelsvector<int32_t>(const Tensor& y, int64_t n)Rank-1 i32 of length n, every label ≥ 0.L36–44
NumClassesint(const vector<int32_t>&)1 + the largest label, so classes are always 0..K−1 even when some are missing.L46–48
VectorTensor, LabelTensorTensor(const vector<double>&), Tensor(const vector<int32_t>&)Back to f32 [n] and i32 [n].L50–61
ArgMaxRowsTensor(const linalg::Matrix&)Index of each row's largest entry, first one on ties, which is why every classifier's Predict breaks ties toward the smallest label.L63–73

The models at a glance

HeaderClassesFitDefaults
linear_regression.hLinearRegressionCentered normal equations by Cholesky, or gradient descentl2 0, intercept on, normal equations; GD lr 0.1, 10,000 iterations, tol 1e-7 (L30–39)
logistic_regression.hLogisticRegressionNewton with Armijo backtrackingC 1, intercept on, 100 iterations, tol 1e-8 (L29–36)
knn.hKNeighborsClassifier, KNeighborsRegressorStores the data; brute-force search per queryk 5, uniform weights (L25–30)
kmeans.hKMeansk-means++ then Lloyd, best of several restartsk 8, 4 restarts, 300 iterations, tol 1e-4, seed 0 (L30–38)
pca.hPcaSVD of the centered dataall min(n, d) components, no whitening (L26–31)
naive_bayes.hGaussianNb, MultinomialNbCounting, in log spacevar_smoothing 1e-9; alpha 1 (L32–34, L59–61)
decision_tree.hDecisionTreeClassifier, DecisionTreeRegressorGreedy CART: sort and sweep each featureGini; unlimited depth, split 2, leaf 1, all features (L38–46)
random_forest.hRandomForestClassifier, RandomForestRegressorBootstrap samples, one tree each, in parallel100 trees, bootstrap on; √d features per split for the classifier, all for the regressor (L29–35, L60–65)
gradient_boosting.hGradientBoostingRegressor, GradientBoostingClassifierHistogram trees grown best-first on gradients and Hessians100 rounds, lr 0.1, 31 leaves, unlimited depth, leaf 20, l2 0, 255 bins (L35–43)
svm.hSupportVectorClassifierSMO on the dual with a full kernel matrixRBF, C 1, gamma "scale", degree 3, coef0 0, tol 1e-3, 107 iterations (L46–56)

Linear models

LinearRegression

The objective is ‖y − Xw − b‖² + l2‖w‖², ordinary least squares at l2 = 0 (linear_regression.h L8–19). Fit centers X and y when fit_intercept is on, which removes the intercept from the problem and keeps it unpenalized, solves for w, then recovers b = mean(y) − mean(x)·w (linear_regression.cc L67–102).

  • Normal equations. Forms XcᵀXc + l2·I and factors it with Cholesky; a failed factorization is the singular case and returns InvalidArgument with "set l2 > 0 for ridge regression" (L19–31).
  • Gradient descent. Full-batch steps on the objective divided by n, stopping once the gradient's largest entry is below tolerance; a non-finite gradient returns "gradient descent diverged; lower the learning rate" (L33–63). The header states the convergence condition, lr < 2 / λmax, and says to standardize first.

LogisticRegression

Binary problems fit one weight row through a sigmoid, K > 2 classes fit K rows through a softmax. The objective is the summed negative log-likelihood plus ‖W‖²/(2C), scikit-learn's, so the optimum is the same and the intercept is unpenalized (logistic_regression.h L7–18). Parameters are packed one output at a time as [w_0 … w_{d−1}, b] against an augmented input [x, 1] (logistic_regression.cc L20–34).

  1. Each iteration builds the full gradient and Hessian: residual p − y and curvature p(1 − p) (binary) or diag(p) − ppᵀ (softmax), plus 1/C on the weights and a 10−8 jitter on the whole diagonal, since a softmax's biases are only fixed up to a constant (L93–143).
  2. The Newton step solves H·d = g by Cholesky; then Armijo backtracking halves t until the objective drops by 10−4·t·gᵀd, up to 50 halvings (L171–189).
  3. It stops when the largest gradient entry is under tolerance, or when no step lowers the objective at all, which it treats as converged at double precision (L163–199). The loss uses max(z, 0) + log1p(e−|z|) − yz, so separable data never overflows (L72–75).

coef() is [1, d] for two classes, the class-1 weights as in scikit-learn, and [K, d] otherwise (L49–54). The Hessian has (outputs·(d + 1))² entries, so the header scopes the class to small d.

Neighbors and clusters

k-nearest neighbors

Fit stores the training matrix and fails when k > n (knn.cc L63–78). Each query computes every squared distance, partial_sorts the k smallest with ties broken by training-row order, and takes square roots only of those (L21–39). Weights are uniform or 1/r; when the nearest neighbor is at distance 0, only the exact matches count, since their weight would be infinite (L41–55). Queries run under ParallelFor with a grain of 16 (L61); each writes only its own output row.

KMeans

  • Seeding. KMeansPlusPlus picks the first center uniformly, then each next one with probability proportional to its squared distance from the nearest chosen center; with fewer distinct points than k it falls back to a uniform pick (kmeans.cc L104–140).
  • Lloyd. Assign every point to its nearest center (lowest index on ties), move every center to its points' mean, stop when the summed squared center shift is at most the tolerance (L60–98). An empty cluster restarts at the point farthest from its center (L78–84).
  • Tolerance and restarts. tolerance is relative: it is multiplied by the data's mean per-feature variance (L151–160). Restart r draws from Generator(seed, r), and the run with the least inertia wins (L144–174).

PCA and naive Bayes

Pca

Fit centers X and takes its SVD rather than eigendecomposing the covariance, which would square the condition number (pca.h L7–15). The components are the first c columns of V, the explained variance is s²/(n − 1), and the ratio divides by the total over all singular values (pca.cc L19–62). The sign convention that makes components match scikit-learn, largest-magnitude entry positive, lives in SvdFactor itself (svd.h L24). Transform projects and, with whiten, divides by √variance; InverseTransform undoes both (L64–104).

GaussianNb and MultinomialNb

Both fit by counting and score in log space, normalizing each row with log-sum-exp (naive_bayes.cc L30–45). Every class in 0..max(y) needs at least one sample, or Fit returns InvalidArgument (L20–28).

  • Gaussian. Per-class means and population variances, plus var_smoothing times the largest variance of any feature over the whole data, so a feature that is constant within a class does not divide by zero (L57–104).
  • Multinomial. Smoothed log-probabilities log(count + α) − log(total + dα); negative counts return InvalidArgument, and a zero count skips its term so α = 0 cannot give 0·log 0 (L134–184).

Trees

The tree code is split into a public pair of estimators and an internal grower that the forest reuses (decision_tree.h L48–89). A Tree is a flat vector of TreeNodes with the root at index 0; a node with feature = −1 is a leaf, and value holds class frequencies or the one mean target.

Growing a CART tree

  1. Leaf test. A node is a leaf at max_depth, below min_samples_split, below twice min_samples_leaf, or with impurity ≤ 10−12 (decision_tree.cc L191–193).
  2. Sweep. For each candidate feature, sort the node's (value, row) pairs, then move rows one at a time from a right Stats to a left one, which keeps counts (or sum and sum of squares) up to date in O(1). Each boundary between distinct values that leaves both sides ≥ min_samples_leaf is scored by nL·IL + nR·IR, and the threshold is the midpoint (L128–158).
  3. Zero-gain splits. The best split is taken even when it lowers impurity by nothing, which XOR needs before its second level pays off (decision_tree.h L15–18).
  4. Recurse. std::partition the rows in place, add n·I − (nLIL + nRIR) to the feature's importance, and recompute a regression child's variance in two passes, so a constant child is exactly pure (L180–220).

Worked example. The first tree test fits x = 1, 2, 3, 10, 11, 12 with labels 0, 0, 0, 1, 1, 1 (decision_tree_test.cc L23–33). The root's Gini impurity is 1 − (0.5² + 0.5²) = 0.5. The sweep scores five boundaries:

The CART sweep on six one-feature points Six points on a number line: 1, 2 and 3 of class 0, and 10, 11 and 12 of class 1. Candidate thresholds sit halfway between neighbours: 1.5, 2.5, 6.5, 10.5 and 11.5. Their weighted child impurities, n left times Gini left plus n right times Gini right, are 2.4, 1.5, 0, 1.5 and 2.4. The split at 6.5 has weighted impurity 0 and wins; it adds 6 times 0.5 minus 0, which is 3, to feature 0's importance, and gives a tree of depth 1 with two pure leaves. 1 2 3 10 11 12 class 0 class 1 2.4 1.5 1.5 2.4 t = 6.5: weighted 0 Score at each boundary: nL·Gini(L) + nR·Gini(R). At t = 2.5: 2·0 + 4·(1 − 1/16 − 9/16) = 1.5. Winner t = 6.5 = 3 + (10 − 3)/2. Importance of feature 0 += 6·0.5 − 0 = 3. Result: depth 1, two pure leaves; the test predicts 6.4 → 0, 6.6 → 1 and −100 → 0.
The sweep behind SplitsHalfwayBetweenClasses: one sort, five candidate thresholds, each scored from running class counts.

Limits and feature subsets

ItemMeaningSource
SplitCriterionkGini (1 − Σp²) or kEntropy (−Σp log₂ p) for classifiers; regressors always use variance.decision_tree.cc L41–61
TreeLimitsmax_depth −1 (unlimited), min_samples_split 2, min_samples_leaf 1, max_features 0, feature_subset kAll.decision_tree.h L38–46
FeaturesPerSplitmax_features when positive, else ⌊√d⌋ or ⌊log₂ d⌋ for kSqrt / kLog2, clamped to [1, d].L237–248
SubsamplingWith fewer than d features per split, a partial Fisher–Yates shuffle draws features until enough non-constant ones have been tried, so constant features never use up the budget.L160–178
feature_importances()The summed impurity decreases normalized to 1; all zero for a single leaf.L271–279

Random forests

GrowForest grows tree t inside a ParallelFor with grain 1, drawing the bootstrap rows and the feature subsets from Generator(seed, t), so the forest depends on the seed and not on which thread grew which tree (random_forest.cc L22–43). Bootstrap rows may repeat, which GrowTree accepts. The classifier's default limits are {-1, 2, 1, 0, FeatureSubset::kSqrt} (random_forest.h L32); the regressor uses all features. Predictions average the trees' leaf values (class frequencies or means), and importances average each tree's normalized importances (L45–55).

Gradient boosting

Boosting fits F(x) = F₀ + Σ lr·ft(x), each tree on the per-sample gradient g and Hessian h of the loss at the current scores. A leaf with sums G and H outputs the Newton step −G/(H + l2), and a split's gain is GL²/(HL + l2) + GR²/(HR + l2) − G²/(H + l2) (gradient_boosting.h L7–24). The shared loop is internal::Booster; the regressor uses squared error, the classifier log loss with one tree per round for two classes and one per class otherwise (L76–102).

PieceWhat it doesSource
FitBinMapperPer feature: midpoints between distinct values when there are at most max_bins of them, otherwise quantile thresholds that always stay below the column's maximum, so the top bin is never empty. max_bins must be in [2, 256], since bins are uint8_t.gradient_boosting.cc L30–55
BinnedDataEvery value's bin, stored feature-major so one feature's histogram reads one contiguous column.L98–129
BuildHistogramSums (g, h, count) per bin, one feature per ParallelFor task, each writing its own slice of the histogram.L176–190
FindSplitSweeps each feature's bins left to right; skips splits with a child under min_samples_leaf or with Hessian under 10−3; keeps only positive gain.L194–216
SplitLeafPartitions the rows, builds the smaller child's histogram and gets the larger one by subtracting it from the parent's.L218–256
GrowBest-first: repeatedly splits the leaf whose best split has the largest gain until max_leaf_nodes leaves or no positive gain, then writes −lr·G/(H + l2) into each leaf and adds it to the training scores.L138–173
Booster::FitStarts from the mean target, the log-odds of the prior (binary), or log priors (multiclass, clamped to [10−15, 1 − 10−15]); each round computes g and h (F − y and 1; p − y and p(1 − p), per class under softmax), grows one tree per column, and records the mean training loss.L283–370

Worked example. One round with lr 1, l2 2 and leaf size 1 on x = 0, 1, 2, 3, y = 0, 0, 10, 10 (gradient_boosting_test.cc L52–70): the baseline is the mean, 5, so g = F − y = 5, 5, −5, −5 and h = 1. The split at 1.5 has GL = 10, HL = 2 on the left, so that leaf outputs −10/(2 + 2) = −2.5 and the right one +2.5, and the predictions are 2.5 and 7.5. Its gain is 10²/4 + 10²/4 − 0 = 50; the code drops the factor ½ the header mentions, which does not change which split wins.

Support vector machine

SupportVectorClassifier is binary, labels 0 and 1 mapped to −1 and +1, and solves the soft-margin dual, min ½ Σ αiαjyiyjKij − Σ αi subject to 0 ≤ α ≤ C and Σ αiyi = 0, by sequential minimal optimization (svm.h L8–27). The full n × n kernel matrix is computed once, which the header scopes to a few thousand points.

  1. Kernel and gamma. Linear x·z, polynomial (γx·z + coef0)degree or RBF exp(−γ‖x − z‖²); gamma = 0 resolves to 1/(d·Var(x)) over every entry, scikit-learn's "scale" (svm.cc L24–49).
  2. Working set. The gradient Qα − 1 starts at −1 with α = 0. Each step picks i, the largest violator −ytGt among indices that can move up, and j, the index in the low set with the largest guaranteed decrease b²/a, LIBSVM's second-order rule, flooring the curvature a at 10−12 (L81–128).
  3. Stop. When the maximal violation is under tolerance (1e-3, LIBSVM's default); reaching max_iterations first returns Internal (L129–134).
  4. Step. Move αi and αj along the line that keeps Σαy fixed, to the parabola's minimum clipped to the box, snap values within 10−12·C of a bound onto it, and update the gradient in O(n) (L136–153).
  5. Bias and support. b averages over the free support vectors, or takes the midpoint of the interval the bounded ones allow when none is free; the support vectors (α > 0) and their α·y are kept for prediction (L156–194).

Checked against scikit-learn

tools/make_sklearn_fixtures.py writes rml/classical/testdata/sklearn.json: small datasets from a default_rng(0) stream, rounded through float32 so both sides see identical inputs, and what scikit-learn fits on them (make_sklearn_fixtures.py L27–29). The committed file records scikit-learn 1.9.1. sklearn_fixture_test reads it with nlohmann/json, so bazel test needs no Python (sklearn_fixture_test.cc L29–36, BUILD.bazel L275–292).

FixtureData and scikit-learn settingsWhat rml must matchSource
linear40 × 3, y = x·[1.5, −2, 0.5] + 0.3 + noise; LinearRegression, Ridge(alpha=1.5)OLS and ridge coefficients and intercepts to 1e-5L40–49, test L66–81
logistic_binary, logistic_multinomial60 × 2 at C = 0.5; three 30-point blobs at C = 1; tol=1e-12Coefficients and intercepts to 1e-4, the first five probability rows to 1e-5 (one TEST_P, two cases)L51–68, test L83–98
pca50 × 4 correlated, 3 componentsExplained variance and singular values to 1e-4, ratios to 1e-6, and |cos| between matching components within 1e-5 of 1L70–78, test L100–119
gaussian_nb, multinomial_nb45 points in three blobs; 30 Poisson count rows at α = 0.5Priors, means, smoothed variances, log-probabilities and the first five probability rowsL80–98, test L121–142
knn40 × 2 XOR-signed labels; k = 5 distance-weighted classifier, k = 4 uniform regressor; 10 queriesProbabilities and predictions to 1e-5L100–110, test L144–160
decision_tree_regressor60 points of a noisy sine, one feature so split ties cannot differ; depth 3, leaf 3Predictions at 13 query points to 1e-5L112–121, test L162–170
kmeans80 points in four well-separated blobs, n_init=10Inertia to 1e-3 (rml run with tolerance 1e-10); the fixture's centers are not comparedL123–131, test L172–180
svm80 overlapping points; RBF ("scale"), linear, and degree-3 polynomial with γ 0.5, coef0 1; C = 2, tol=1e-6The resolved gamma, the exact support set, the intercept and decision values at 10 queries to 1e-4L133–146, test L182–206

Random forests, gradient boosting and the tree classifier have no fixture: their results depend on random draws and tie-breaking that scikit-learn makes differently, so their own tests check properties instead.

Who uses it

Two callers outside the module. Retrieval's IvfIndex::Train clusters a sample into its inverted lists with KMeans at one restart and 50 iterations (ivf.cc L39–47), and the time-series example fits its seasonal autoregression with LinearRegression (time_series.cc L115–130). The inference stack leaves classical ML out of scope.

Tests

FileTestsWhat they pin down
linear_regression_test.cc6An exact linear map recovered, fits through the origin without an intercept, ridge shrinking toward zero, collinear features failing until l2 > 0, gradient descent agreeing with the normal equations, and divergence reported.
logistic_regression_test.cc6The symmetric two-point closed form w = 2σ(−w) ≈ 0.6748 with b = 0, a stationary binary fit, a stronger penalty shrinking weights, multinomial blobs separated, separable data staying finite, one class an error.
knn_test.cc5Votes among the nearest, distance weights favoring closer points, deterministic tie-breaks, k > n an error, the regressor's mean.
kmeans_test.cc6Separated blobs recovered, inertia against a hand computation, same seed same result, distinct k-means++ seeds, more clusters than distinct points, k > n an error.
pca_test.cc6The long axis found, uncorrelated projections with the component variances, a full inverse round trip, unit-variance whitening, truncated reconstruction dropping minor variance, bad component counts rejected.
naive_bayes_test.cc5A hand-computed Gaussian posterior, smoothing over constant features, a missing class an error, Laplace-smoothed multinomial estimates, negative counts an error.
decision_tree_test.cc10The halfway split above, XOR through a zero-gain split under both criteria, depth 0 as the class prior, importance on the informative feature, leaf and split limits, seeded feature subsampling, FeaturesPerSplit, a regression stump's side means, memorizing distinct points, a constant target as one leaf.
random_forest_test.cc4A forest beating a single tree by 3 points on noisy data, same seed same forest, one tree without bootstrap or subsampling equal to a decision tree, averaging lowering regression test error.
gradient_boosting_test.cc9Midpoint and quantile bins, the one-round Newton step above, zero rounds predicting the mean or the prior, a noisy sine, a curved binary boundary, multiclass blobs, one class an error.
svm_test.cc7The maximum-margin line on separable blobs, a smaller C giving a wider soft margin, XOR by RBF and by a degree-2 polynomial, the scaled gamma, bad labels rejected, non-convergence reported.
sklearn_fixture_test.cc9The fixture table above: eight TESTs and one TEST_P run for the binary and multinomial logistic fixtures.