This site is version 0.0.1. Most of the code on this site is drafted by an agent and needs review. I apologize for the AI-speak, we are working on rewriting it!
viv-lang.org

A scikit-learn pipeline as one C file

Fit a scikit-learn pipeline (scaling, one-hot encoding and gradient-boosted trees) on a synthetic loan-default table, and compile it with vipyr.compile.compile_model into a single C99 file that needs only libc. The compiler checks the C against scikit-learn on every held-out row before it returns, and embeds a few hundred rows with scikit-learn's outputs so the file can recheck itself on another machine (./model --selftest).

The model

6,000 synthetic applicants: income, debt-to-income ratio, credit history length, number of recent inquiries and a home-ownership category. The label comes from a logistic model with an interaction, so trees have something to find. The pipeline is a ColumnTransformer (StandardScaler on the numbers, OneHotEncoder on the category) and a GradientBoostingClassifier of 120 trees of depth 3.

import json
import tempfile
import warnings
from pathlib import Path

warnings.filterwarnings("ignore")
import numpy as np
import pandas as pd
from sklearn.compose import ColumnTransformer
from sklearn.ensemble import GradientBoostingClassifier
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler
from vipyr.compile import compile_model, load_model
from vipyr.figure import figure

rng = np.random.default_rng(4)
n = 6000
df = pd.DataFrame(
    {
        "income": np.round(rng.lognormal(10.8, 0.5, n), 0),
        "dti": np.round(rng.beta(2.0, 5.0, n), 4),
        "history_years": np.round(rng.gamma(3.0, 3.0, n), 1),
        "inquiries": rng.poisson(1.2, n).astype(float),
        "home": rng.choice(
            ["rent", "own", "mortgage"], n, p=[0.45, 0.15, 0.40]
        ),
    }
)
logit = (
    -2.0
    + 4.0 * df.dti
    - 0.6 * np.log(df.income / 50000)
    - 0.08 * df.history_years
    + 0.35 * df.inquiries
    + 1.5 * df.dti * (df.home == "rent")
    - 0.4 * (df.home == "own")
)
y = (rng.random(n) < 1 / (1 + np.exp(-logit))).astype(int)
train, test = df.iloc[:4500], df.iloc[4500:].reset_index(drop=True)
num = ["income", "dti", "history_years", "inquiries"]
pipe = Pipeline(
    [
        (
            "prep",
            ColumnTransformer(
                [
                    ("num", StandardScaler(), num),
                    ("cat", OneHotEncoder(), ["home"]),
                ]
            ),
        ),
        (
            "gbm",
            GradientBoostingClassifier(
                n_estimators=120, max_depth=3, random_state=0
            ),
        ),
    ]
).fit(train, y[:4500])
print(
    "default rate %.3f; held-out accuracy %.3f"
    % (y.mean(), pipe.score(test, y[4500:]))
)
default rate 0.307; held-out accuracy 0.730

Compile and check

compile_model writes the pipeline as readable Viv (loans.viv), exports it to C with viv export, builds it, and compares its outputs with scikit-learn's on all 1,500 held-out rows. It returns a report.

work = Path(tempfile.mkdtemp())
rep = compile_model(pipe, test, work / "loans.c", log=lambda *a: None)
print("C file: %d lines; estimator %s" % (rep["lines"], rep["estimator"]))
print(
    "rows verified: %d, outputs per row: %d"
    % (rep["rows_verified"], rep["outputs_per_row"])
)
print(
    "largest difference from scikit-learn: %.1e (%d ulp), labels identical: %s"
    % (rep["max_abs_diff"], rep["max_ulps"], rep["labels_identical"])
)
print("self-test:", rep["builds"][0]["selftest"])
print("the first lines of the generated Viv:")
print("\n".join((work / "loans.viv").read_text().splitlines()[:12]))
C file: 10788 lines; estimator GradientBoostingClassifier
rows verified: 1500, outputs per row: 3
largest difference from scikit-learn: 6.9e-18 (1 ulp), labels identical: True
self-test: selftest passed: 300 rows, 900 values; largest difference from scikit-learn 0 (allowed 2.77556e-17), and bit-identical to the values recorded at compile time
the first lines of the generated Viv:
# loans: a scikit-learn model as Viv, written by `vipyr compile`; do not edit.
#
# score(xf, xi, rows) takes `rows` input rows: xf holds the float columns
# (rows x NF, row-major) and xi the integer columns and category codes
# (rows * NI, row-major). It returns rows x 3: the probability of each class, then the predicted class's index.
#
# float columns (xf): income, dti, history_years, inquiries
# integer columns (xi): home (category code)

NF: Int = 4
NI: Int = 1
NZ: Int = 7

The compiled model's predictions against scikit-learn's

load_model builds the C as a shared library and calls it on a DataFrame. The scatter plot is the default probability from the C file against scikit-learn's for every second held-out row: they lie on the diagonal. Of the 1,500 rows, one differs at all, by one unit in the last place, within the tolerance the compiler allows.

m = load_model(work / "loans.c")
p_c = m.predict_proba(test)[:, 1]
p_sk = pipe.predict_proba(test)[:, 1]
d = p_c - p_sk
print(
    "max |p_C - p_sklearn| = %.1e over %d rows; %d rows differ at all"
    % (np.abs(d).max(), len(d), int(np.sum(d != 0)))
)
fig = figure(
    "Probability of default: compiled C against scikit-learn",
    "scikit-learn",
    "C file",
)
fig.scatter(p_sk[::2], p_c[::2], "held-out rows")
fig.plot([0, 1], [0, 1], "y = x")
fig.style("y = x", "s2 dash thin nomarker")
max |p_C - p_sklearn| = 6.9e-18 over 1500 rows; 1 rows differ at all
Probability of default: compiled C against scikit-learnheld-out rowsy = x00.51.000.20.40.60.81.0scikit-learnC file

Latency

Measured by examples/deploy/deploy.py on its own pipeline (150 trees of depth 3, a Viv feature function and 30,000 rows), and committed in examples/deploy/results.json. Each tree is written as nested ifs with its thresholds as constants, so the C compiler sees straight-line comparisons and no table lookups. The C library, called through ctypes on a DataFrame and computing the features too, scores the batch faster than scikit-learn's predict_proba on the frame that already holds the features, and one row in microseconds where scikit-learn's per-call overhead takes hundreds.

res = json.load(open("examples/deploy/results.json"))
sp = res["speed"]
for k in sorted(sp):
    if k.endswith("_rows_per_s"):
        print("%-48s %12.0f rows/s" % (k[: -len("_rows_per_s")], sp[k]))
print(
    "one row: C %.0f us, scikit-learn %.0f us"
    % (sp["single_row_c_us"], sp["single_row_sklearn_us"])
)
print(
    "rows: %d; compile and check, total %.1f s"
    % (sp["rows"], res["compile_total_s"])
)
c_library                                             1959136 rows/s
cli_csv                                                577098 rows/s
sklearn_predict_proba                                 1011688 rows/s
sklearn_with_viv_features                              971786 rows/s
one row: C 19 us, scikit-learn 851 us
rows: 30000; compile and check, total 3.8 s

Source

gallery/ml/plot_sklearn_to_c.py, run as written when these pages were built. The output and the figures above are from that run.