hiw3c 日报精选
MarkTechPost RSS 全文

How to Make Zero-Training Tabular Predictions with NVIDIA Structured Data Models

In this tutorial, we implement Structured Data Models, NVIDIA’s new library that puts several pretrained foundation models for tables behind one PyTorch interface, and use it to make predictions witho

来源:MarkTechPost · www.marktechpost.com

正文为英文,可一键机器翻译(仅首次需要等待)

In this tutorial, we implement Structured Data Models, NVIDIA’s new library that puts several pretrained foundation models for tables behind one PyTorch interface, and use it to make predictions without training. The library wraps TabICLv2 and KumoTabular for single tables and KumoRelational for whole relational databases, and each of them answers by in-context learning: we hand the model a set of labeled rows and the rows we want predicted, and it returns probabilities or a full predictive distribution in one forward pass. We start with the library’s typed table tensors, compare the two tabular models against trained scikit-learn baselines, reuse an encoded context across many queries, read calibrated prediction intervals off a regression, and scale to twenty classes. We then look at the places where the defaults quietly do the wrong thing: a preprocessing recipe that drops timestamp and text columns and an explainer with two traps in it, before building a small three-table shop database and predicting customer activity from it directly, including the one argument that separates an honest relational model from a leaky one. Check out the Full CODES here.

import os
import sys
import time
import textwrap
import traceback
import subprocess
 
RESULTS = {}
 
 
def banner(title):
    print("\n" + "=" * 78)
    print(title)
    print("=" * 78)
 
 
def section(name):
    def wrap(fn):
        def run(*a, **kw):
            banner(name)
            try:
                out = fn(*a, **kw)
                RESULTS[name] = out if isinstance(out, str) else "ok"
                return out
            except Exception as e:
                RESULTS[name] = f"SKIPPED / FAILED -> {type(e).__name__}: {e}"
                print(f"\n[!] {name} did not complete: {type(e).__name__}: {e}")
                traceback.print_exc(limit=3)
                return None
        return run
    return wrap
 
 
banner("1. Install SDM, and the one wheel URL its own error message gets wrong")
subprocess.run([sys.executable, "-m", "pip", "install", "-q", "git+https://github.com/NVIDIA/structured-data-models.git@b982fbf4188ea8db17249fe81338723106100b63"], check=True)
 
import torch
 
# The relational sampler needs pyg-lib on CPU. Its wheels are published per MINOR torch version
# (torch-2.14.0+cpu.html), but SDM's own ImportError builds the URL from the exact patch version
# (torch-2.14.1+cpu.html), which is an empty page. Build it from MAJOR.MINOR.0 instead.
major, minor = torch.__version__.split("+")[0].split(".")[:2]
flavour = "cpu" if torch.version.cuda is None else "cu" + torch.version.cuda.replace(".", "")
PYG_INDEX = f"https://data.pyg.org/whl/torch-{major}.{minor}.0+{flavour}.html"
subprocess.run([sys.executable, "-m", "pip", "install", "-q", "pyg-lib", "-f", PYG_INDEX], check=True)
 
import numpy as np
import pandas as pd
import pyg_lib
import sdm
import sdm.processing as sp
from sklearn.datasets import load_breast_cancer, load_diabetes, make_classification
from sklearn.ensemble import HistGradientBoostingClassifier, HistGradientBoostingRegressor
from sklearn.linear_model import LogisticRegression, RidgeCV
from sklearn.metrics import roc_auc_score
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
 
DEVICE = "cpu"   # verified end to end on CPU; on a T4 runtime set "cuda" for the tabular models
print(f"  sdm {sdm.__version__}  |  torch {torch.__version__}  |  pyg-lib {pyg_lib.__version__}"
      f"  |  device {DEVICE}")
print(f"  pyg-lib wheels from {PYG_INDEX}")
print("\n  One interface over several pretrained structured-data foundation models:")
for name, what, weights in [
    ("TabICLv2", "tabular, in-context learning (ICML '26)", "~110 MB per task head, BSD-3"),
    ("KumoTabular", "tabular, small / medium / large", "small ~110 MB, large ~855 MB, OpenMDW"),
    ("KumoRelational", "multi-table, KumoRFM-2", "~120 MB, OpenMDW"),
    ("TabFM, TimesFM 3", "Google tabular / time series", "6.5 GB / 1.3 GB, non-commercial: not used here"),
]:
    print(f"    {name:18s} {what:42s} {weights}")
print("\n  No model below is trained or fine-tuned. Every prediction is a forward pass that reads")
print("  labelled 'context' rows and answers 'query' rows, like a prompt with examples in it.")

We install Structured Data Models from the GitHub repository, pinned to the commit this notebook was verified against, since the package on PyPI is only a placeholder. The relational sampler also needs pyg-lib, and that is where the first trap lies. pyg-lib wheels are published per minor PyTorch release, on pages named like torch-2.14.0+cpu, but the library’s own ImportError builds the URL from the exact patch version, torch-2.14.1+cpu, which is an empty page, so following its advice fails. We build the index URL from the major and minor version instead. We run everything on the CPU, which is the path verified end to end here, and print the models the library exposes together with their checkpoint sizes and licenses; the two Google models it also wraps, TabFM and TimesFM 3, are large and non-commercial and are left out. Check out the Full CODES here.

@section("2. Typed tables: what infer_stypes decides, and what a round trip changes")
def typed_tables():
    rng = np.random.default_rng(0)
    words = ["late", "refund", "great", "broken", "fast", "charged", "app", "order", "support", "item"]
    df = pd.DataFrame({
        "ticket_id": np.arange(400),
        "amount": rng.gamma(2, 30, 400).round(2),
        "units": pd.array(rng.integers(1, 5, 400), dtype="Int64"),
        "channel": rng.choice(["web", "app", "phone", None], 400),
        "opened_at": pd.Timestamp("2026-01-01") + pd.to_timedelta(rng.integers(0, 86_400 * 90, 400), unit="s"),
        "comment": [" ".join(rng.choice(words, rng.integers(3, 9))) + f" #{i}" for i in range(400)],
    })
    df.loc[rng.choice(400, 25, replace=False), "units"] = pd.NA
 
    print("  infer_stypes(df) with the defaults, then asking it to look for text and ids:")
    for kw in ({}, {"text": "infer", "id": "infer"}):
        stypes = sdm.infer_stypes(df, **kw)
        print(f"    {str(kw or 'defaults'):34s} {', '.join(f'{c}={s}' for c, s in stypes.items())}")
    print("  With the defaults the free-text comment becomes a 400-level categorical and the id column a")
    print("  number. Both would reach the model as features. Override, or ask: text='infer', id='infer'.")
 
    table = sdm.TableTensor.from_pandas(df, sdm.infer_stypes(df, text="infer", id="infer"))
    print("\n" + textwrap.indent(repr(table), "  "))
 
    back = table.to_pandas()
    print("\n  to_pandas() round trip:")
    for col in df.columns:
        a, b = df[col], back[col]
        if col in ("amount", "units"):
            equal_values = bool(np.allclose(a.astype(float), b.astype(float), equal_nan=True))
        else:
            na_a, na_b = a.isna().to_numpy(), b.isna().to_numpy()
            equal_values = bool((na_a == na_b).all() and
                                (a[~na_a].astype(str).to_numpy() == b[~na_b].astype(str).to_numpy()).all())
        print(f"    {col:10s} {str(a.dtype):16s} -> {str(b.dtype):16s} values preserved: {equal_values}")
    print("  Values survive, including the missing ones; dtypes do not always. Numbers come back float32")
    print("  (the numerical block is one float tensor), nullable Int64 becomes float with NaN, and strings")
    print("  marked categorical come back as a pandas category.")
 
    base = pd.to_datetime(["2026-01-01 08:00:00"] * 3).as_unit("ns")
    whole = pd.DataFrame({"t": base})
    nanos = pd.DataFrame({"t": base + pd.to_timedelta([0, 0, 1], unit="ns")})
    print(f"\n  datetime64[ns], whole seconds:      {sdm.TableTensor.from_pandas(whole, {'t': 'datetime'}).size()} OK")
    try:
        sdm.TableTensor.from_pandas(nanos, {"t": "datetime"})
    except Exception as e:
        print(f"  same column, one value 1 ns later:  {type(e).__name__}: {str(e).split(':')[0]}")
    ok = sdm.TableTensor.from_pandas(nanos.assign(t=nanos.t.dt.floor("us")), {"t": "datetime"})
    print(f"  floor('us') first:                  {ok.size()} OK")
    print("  SDM stores datetimes as int64 microseconds and Arrow refuses any cast that drops a digit. The")
    print("  unit is not the problem; a single value with nanosecond digits fails the whole column.")
    return "6 columns -> 5 typed blocks; text and id need opting in"
 
 
typed_tables()

Everything in the library flows through TableTensor, a PyTorch tensor subclass that keeps one typed block per semantic type: numerical, categorical, datetime, text and id. infer_stypes decides the types, and with its defaults it gets two things wrong on a realistic support-ticket frame: the free-text comment becomes a categorical with four hundred levels and the ticket id becomes a number, so both would reach a model as features. Passing text and id inference flags fixes that. The round trip back to pandas preserves every value, missing ones included, but not every dtype: numbers return as float32 because the numerical block is a single float tensor, nullable integers become floats with NaN, and categorical strings return as a pandas category. On pandas 2.x the string columns print as object rather than str and the timestamp goes in at nanosecond resolution; the same values come back either way. One more trap is worth knowing before you load a real event log: the library stores datetimes as integer microseconds, and Arrow refuses any cast that would drop a digit. A nanosecond column of whole seconds converts fine, but a single value with nanosecond digits fails the whole column, so we floor timestamps to microseconds first. Check out the Full CODES here.

def as_table(df, target):
    return sdm.TableTensor.from_pandas(df, sdm.infer_stypes(df, overrides={target: "categorical"}), device=DEVICE)
 
 
@section("3. Zero-training classification: two foundation models vs. two trained baselines")
def zero_training():
    df = load_breast_cancer(as_frame=True).frame.sample(frac=1, random_state=0).reset_index(drop=True)
    table = as_table(df, "target")
    n_ctx = 300
    x_ctx, y_ctx = table[:n_ctx].drop_columns("target"), table[:n_ctx, "target"]
    x_q, y_q = table[n_ctx:].drop_columns("target"), df.target.to_numpy()[n_ctx:]
    rows = []
    for name, make in [("TabICLv2", lambda: sdm.models.TabICLv2(device=DEVICE)),
                       ("KumoTabular (small)", lambda: sdm.models.KumoTabular(size="small", device=DEVICE))]:
        model = make()
        torch.manual_seed(0)
        proba = model(x_context=x_ctx, y_context=y_ctx, x_query=x_q, num_estimators=4).to_pandas()
        rows.append((name, "none", proba.idxmax(axis=1).astype(int).to_numpy(), proba["1"].to_numpy()))
    X, y = df.drop(columns="target"), df.target
    for name, est in [("LogisticRegression", make_pipeline(StandardScaler(), LogisticRegression(max_iter=2000))),
                      ("HistGradientBoosting", HistGradientBoostingClassifier(random_state=0))]:
        est.fit(X[:n_ctx], y[:n_ctx])
        rows.append((name, "fit on 300 rows", est.predict(X[n_ctx:]), est.predict_proba(X[n_ctx:])[:, 1]))
    print(f"  breast cancer: {n_ctx} labelled rows, {len(y_q)} to predict, 30 numeric features\n")
    print(f"  {'model':22s} {'training':16s} {'accuracy':>9s} {'AUC':>7s}")
    for name, training, pred, score in rows:
        print(f"  {name:22s} {training:16s} {np.mean(pred == y_q):9.3f} {roc_auc_score(y_q, score):7.3f}")
    print("\n  The output is itself a TableTensor: one probability column per class, named by label.")
    best = max(rows[:2], key=lambda r: np.mean(r[2] == y_q))
    return f"{best[0]} {np.mean(best[2] == y_q):.3f} accuracy with no training"
 
 
zero_training()

The tabular models take labelled context rows and unlabelled query rows and return a TableTensor with one probability column per class, named by label, so there is no fit step and no hyperparameters. On the breast cancer dataset, with three hundred labelled rows and two hundred and sixty-nine to predict, TabICLv2 and KumoTabular reach 0.970 accuracy and AUCs of 0.996 and 0.995 with no training, against 0.970 and 0.993 for a logistic regression and 0.967 and 0.992 for gradient boosting, both fitted on the same three hundred rows. num_estimators sets how many differently preprocessed views of the data the model averages over, which is the library’s built-in ensembling.

@section("4. fit() once, predict() many: the cached context")
def kv_cache():
    df = load_breast_cancer(as_frame=True).frame.sample(frac=1, random_state=0).reset_index(drop=True)
    table = as_table(df, "target")
    x_ctx, y_ctx = table[:400].drop_columns("target"), table[:400, "target"]
    batches = [table[400 + 30 * i: 430 + 30 * i].drop_columns("target") for i in range(5)]
    model = sdm.models.TabICLv2(device=DEVICE)
 
    torch.manual_seed(0)
    t0 = time.time()
    for b in batches:
        model(x_context=x_ctx, y_context=y_ctx, x_query=b, num_estimators=4)
    t_forward = time.time() - t0
 
    torch.manual_seed(0)
    t0 = time.time()
    model.fit(x=x_ctx, y=y_ctx, num_estimators=4)
    t_fit = time.time() - t0
    t0 = time.time()
    for b in batches:
        model.predict(b)
    t_predict = time.time() - t0
    model.clear()
    print("  five batches of 30 query rows against the same 400 context rows:")
    print(f"    forward() x5                      {t_forward:6.2f}s  (context re-encoded every call)")
    print(f"    fit() once + predict() x5         {t_fit + t_predict:6.2f}s  (fit {t_fit:.2f}s, predicts {t_predict:.2f}s)")
    print(f"    per batch: {t_forward / 5:.2f}s vs {t_predict / 5:.2f}s")
    print("\n  fit() encodes the context once into a key/value cache; predict() only runs the queries.")
    print("  clear() frees it. Nothing is learned: fit() here means 'remember these examples'.")
    return f"{t_forward / (t_fit + t_predict):.1f}x faster for five batches"
 
 
kv_cache()

Calling the model directly re-encodes the context on every call. When the same labelled rows will answer many batches of queries, fit encodes the context once into a key and value cache and predict then runs only the query rows; clear frees the cache. Nothing is learned in fit, which here means remember these examples. Answering five batches of thirty rows against the same four hundred context rows finished several times faster this way on our CPU, and once the context was cached, each further batch cost a small fraction of a full forward pass. The exact ratio depends on the machine, which is why the cell measures it rather than assuming it.

@section("5. Regression returns a distribution: 999 quantiles, and how honest they are")
def quantile_regression():
    df = load_diabetes(as_frame=True).frame.sample(frac=1, random_state=0).reset_index(drop=True)
    table = sdm.TableTensor.from_pandas(df, sdm.infer_stypes(df), device=DEVICE)
    model = sdm.models.TabICLv2(device=DEVICE)
    torch.manual_seed(0)
    q = model(x_context=table[:300].drop_columns("target"), y_context=table[:300, "target"],
              x_query=table[300:].drop_columns("target"), num_estimators=4).to_pandas()
    y = df.target.to_numpy()[300:]
    print(f"  output: {q.shape[1]} columns {list(q.columns[:3])} ... {list(q.columns[-2:])} per query row")
 
    X, Y = df.drop(columns="target"), df.target
    rm