Inferência tabular no browser (tempestweb.tabular)¶
O que você vai aprender
A rodar um modelo sklearn dentro do browser, sobre uma linha de números — score de risco, previsão de demanda, classificação de lead — sem chamar endpoint e sem quebrar o offline-first. 🧮
O tempestweb já tinha vision/: Classifier/Detector/Segmenter sobre ONNX,
com o modelo rodando no browser. Para dado tabular — o caso mais comum de ML
em app de negócio — não havia nada, e a app precisava chamar um endpoint.
O problema que o manifesto resolve¶
Um modelo ONNX é uma função de um vetor de floats sem rótulo para um número. A ordem carrega todo o significado, e nada no runtime confere:
# ❌ Sem manifesto: isso não falha. Responde um número plausível e errado.
await session.run({"X": [[30.0, 3200.0, 18.0]]}) # idade, renda, tempo? ou renda, idade, tempo?
Uma app que manda {"idade": 30} para um modelo treinado com age lê um zero
onde deveria ler a idade — e nada rio abaixo consegue perceber.
{
"version": "2026-08-27",
"features": ["age", "income", "tenure_months"],
"outputs": ["label", "probabilities"],
"classes": ["low", "high"]
}
Com o manifesto ao lado do .onnx, o mesmo erro vira mensagem:
As duas metades juntas, de propósito
A mensagem lista o que faltou e o que veio no lugar, porque as duas
quase sempre são um typo só. age ausente e idade presente é um erro, não
dois.
Predizendo¶
from tempestweb.tabular import TabularPredictor
PREDICTOR = TabularPredictor("/models/risk.onnx", manifest="/models/risk.json")
async def score(row: dict[str, float]) -> float:
"""Devolve a probabilidade da classe prevista."""
prediction = await PREDICTOR.predict(row)
return prediction.score
A linha vai em qualquer ordem — o manifesto impõe a que o modelo precisa:
Várias linhas de uma vez, numa única execução:
O modelo só é baixado na primeira predição
Construir um TabularPredictor no escopo do módulo não baixa nada. Uma
app que define três preditores e usa um paga por um.
O passo de export: zipmap=False é obrigatório¶
Export padrão do skl2onnx não roda no browser
O default do skl2onnx acrescenta um nó ZipMap, e a saída
probabilities deixa de ser tensor para virar seq(map(int64, float)). O
onnxruntime-web não lê isso:
Can't access output tensor data on index 1.
ERROR_MESSAGE: Reading data from non-tensor typed value is not supported.
Foi medido aqui, com um export real. Passe zipmap: False:
Se esquecer, o native.onnx levanta unsupported_output dizendo isso em
vez de repetir a mensagem do runtime — o modelo de 539 bytes com ZipMap virou
389 bytes sem ele, e passou a rodar.
O export inteiro, num ambiente descartável:
import json
from pathlib import Path
import numpy as np
from skl2onnx import to_onnx
from sklearn.linear_model import LogisticRegression
X = np.column_stack([age, income, tenure]).astype(np.float32)
model = LogisticRegression(max_iter=2000).fit(X, y)
onx = to_onnx(model, X[:1], target_opset=15,
options={id(model): {"zipmap": False}})
Path("risk.onnx").write_bytes(onx.SerializeToString())
Path("risk.json").write_text(json.dumps({
"version": "2026-08-27",
"features": ["age", "income", "tenure_months"],
"outputs": ["label", "probabilities"],
"classes": ["low", "high"],
}))
Por que uvx, e não dependência
sklearn, skl2onnx e numpy são pesados e trazem bounds que, num pacote
publicado, propagam para todo consumidor do tempestweb — por um passo que
acontece uma vez, na máquina de quem treina. Em runtime, nada aqui depende
deles.
Medido em Chrome real¶
Mesmo .onnx, mesmo risk.json, três linhas, comparado contra o que o
sklearn responde em Python:
| Linha | sklearn | Chrome → Python | delta |
|---|---|---|---|
income=2000 tenure=6 |
high p=0,99999702 |
high p=0,99999708 |
5,96e-08 |
income=9000 tenure=90 |
low p=1,00000000 |
low p=1,00000000 |
0 |
income=2500 tenure=12 |
low p=0,66673243 |
low p=0,66673243 |
0 |
Inferência de 3 linhas: 0,3 ms. Segunda carga do mesmo modelo: 2,7 ms,
servida do bucket tw-assets do cache de assets.
O cache é o que já existe
O modelo passa pelo client/offline/asset-cache.js — o mesmo do offline —,
então baixa uma vez por versão e não uma por sessão, e cargas concorrentes da
mesma URL são deduplicadas. Runtime sem Cache Storage degrada para a URL
crua: cache frio é mais lento, não quebrado.
O que pesa não é o modelo — é o runtime¶
Um modelo de 660 bytes, um runtime de 14 MB
O .onnx de uma LogisticRegression de 30 features tem 660 bytes. O
onnxruntime-web que o executa tem 13,96 MB (3,58 MB gzip) no bundle
mais enxuto. O runtime é 21.000× o modelo — e é ele que decide se
inferência tabular cabe no seu artefato, não o modelo.
Export medido (skl2onnx com zipmap=False, scikit-learn sobre
load_breast_cancer e make_classification):
| Modelo | Features | .onnx |
gzip |
|---|---|---|---|
LogisticRegression |
30 | 660 B | 539 B |
DecisionTreeClassifier(max_depth=8) |
30 | 2.167 B | 812 B |
GradientBoostingClassifier(n=100) |
30 | 54.217 B | 7.700 B |
RandomForestClassifier(n=100, d=8) |
30 | 154.013 B | 20.192 B |
LogisticRegression, 3 classes |
120 | 2.259 B | 1.915 B |
RandomForestClassifier(n=300, d=12) |
120 | 14.292.489 B | 1.623.887 B |
Do lado do runtime, onnxruntime-web 1.29.0, medido pelo que o Chrome
realmente baixa:
Bundle carregado por [wasm].scripts |
JS | WebAssembly | total gzip |
|---|---|---|---|
ort.wasm.min.js (só CPU) |
50.196 B | ort-wasm-simd-threaded.wasm — 13.961.845 B |
3,58 MB |
ort.min.js (default do pacote) |
368.008 B | …-threaded.jsep.wasm — 27.797.172 B |
6,48 MB |
ort.all.min.js |
819.591 B | …-threaded.jsep.wasm — 27.797.172 B |
6,64 MB |
Carregue ort.wasm.min.js, não ort.min.js
O bundle default da 1.29.0 puxa o WebAssembly jsep (WebGPU + WebNN)
mesmo quando a sessão pede só executionProviders: ["wasm"] — foi medido
aqui, olhando a aba de rede. Trocar por ort.wasm.min.js economiza
13,8 MB crus / 2,9 MB gzip sem mudar uma linha de Python. É o que o
[wasm].scripts das capacidades
já recomenda.
Para dar escala, o mesmo counter do tutorial buildado nos dois modos, sem ML
nenhum:
| Artefato | cru | gzip |
|---|---|---|
Modo A --offline (Pyodide + stdlib + pydantic vendorados) |
15,6 MB | 8,4 MB |
| Modo C (transpile) | 1,98 MB | 291 KB |
Ou seja: ort.wasm.min.js +43% no gzip de um artefato Modo A offline, e
12× o artefato Modo C inteiro.
Provedor: o default ["wasm"], com o número que o sustenta¶
Inferência medida em Chrome real (onnxruntime-web 1.29.0, 50 execuções,
mediana e p95, sessão já compilada):
| Modelo | Linhas por execução | mediana | p95 |
|---|---|---|---|
LogisticRegression 30f |
3 | 0,1 ms | 0,3 ms |
LogisticRegression 30f |
1.000 | 0,1 ms | 0,3 ms |
RandomForest 100×d8 |
3 | 0,1 ms | 0,1 ms |
RandomForest 100×d8 |
1.000 | 3,4 ms | 3,7 ms |
Criar a sessão custa 225 ms na primeira (o runtime WASM subindo junto) e 2,1–8,3 ms nas seguintes.
WebGPU não foi medido aqui — e o default não depende disso
O ambiente desta medição (WSL2, Chrome headless) expõe navigator.gpu mas
requestAdapter() devolve null, e o onnxruntime-web recusa com
no available backend found. ERR: [webgpu] Failed to get GPU adapter —
inclusive com --enable-unsafe-swiftshader. O que sustenta manter
DEFAULT_PROVIDERS = ["wasm"] é o outro lado da conta: a EP WebGPU exige o
runtime jsep, que custa 2,9 MB gzip a mais, para acelerar uma inferência
que já leva 0,1 ms. Um ganho de 100% ali economiza 0,1 ms e paga com
megabytes.
providers= continua aceitando a ordem que você quiser — quem roda visão e
tabular na mesma página já baixou o jsep e pode experimentar sem custo novo.
Sem runtime nenhum: CompactPredictor¶
Se o runtime é o que pesa, a saída é não ter runtime. Um modelo linear é um
produto escalar; uma árvore é uma cadeia de comparações. Nada disso precisa
de WebAssembly, e é exatamente o que o CompactPredictor lê — em Python de
stdlib (struct, array, math), dentro do Pyodide.
from tempestweb.tabular import CompactPredictor
PREDICTOR = CompactPredictor("./models/risk.tmc")
async def score(row: dict[str, float]) -> float:
"""Devolve a probabilidade da classe prevista."""
prediction = await PREDICTOR.predict(row)
return prediction.score
Mesma API do TabularPredictor: predict(row) / predict_many(rows), linha em
qualquer ordem, mesma Prediction de volta.
Para o .tmc entrar no artefato (e no precache do service worker), declare-o
em [wasm].assets:
O arquivo já é o manifesto
O export grava feature_names e classes dentro do .tmc, então não há
segundo arquivo para manter em sincronia. manifest= existe só para
sobrescrever um export que não recebeu os nomes.
O export: um passo de build, com o escritor que verifica¶
O .tmc é escrito pelo tempest-fastapi-sdk, que compara os bytes contra as
predições do próprio scikit-learn e se recusa a escrever um arquivo que
discorde:
from sklearn.ensemble import RandomForestClassifier
from tempest_fastapi_sdk.modelops import export_sklearn_to_compact
model = RandomForestClassifier(n_estimators=12, max_depth=5).fit(X, y)
export = export_sklearn_to_compact(model, X_test, "dist/risk.tmc", feature_names=list(X.columns))
print(export.kind, export.size_bytes, export.verified) # tree_ensemble 4764 True
É uma troca, não um substituto
ONNX cobre todo estimador; isto cobre modelo linear e ensemble de
árvore — as duas famílias cuja aritmética cabe aqui. Gradient boosting soma
contribuições cruas através de um estimador inicial: é outro leitor, e o
exportador recusa em vez de escrever algo que este leria errado. Para
esses, o caminho é o TabularPredictor.
Cada estimador abaixo tem uma fixture na suíte, escrita pelo publicador do formato e comparada com o que o scikit-learn respondeu para as mesmas linhas. A lista é exatamente o que está medido — nem um nome a mais:
| Estimador | Fixture | Link |
|---|---|---|
LogisticRegression (binário) |
linear_binary_sigmoid |
sigmoid |
LogisticRegression (multiclasse) |
linear_multiclass_softmax |
softmax |
LinearRegression |
linear_regression_identity |
identity |
Ridge |
ridge_regression_identity |
identity |
SGDClassifier |
sgd_classifier_sigmoid |
sigmoid |
LinearSVC |
linear_svc_sigmoid |
sigmoid |
Perceptron |
perceptron_sigmoid |
sigmoid |
DecisionTreeRegressor |
tree_regressor_identity |
identity |
RandomForestClassifier |
forest_classifier_normalize |
normalize |
ExtraTreesClassifier |
extratrees_classifier_normalize |
normalize |
Pipeline + StandardScaler |
pipeline_scaler_linear |
sigmoid |
Pipeline + MinMaxScaler |
pipeline_minmax_linear |
sigmoid |
O escalador é dobrado no header, nunca ignorado: o leitor faz
(valor - offset) / escala, e é nesse par que o exportador escreve
mean_/scale_ do StandardScaler e data_min_/data_range_ do
MinMaxScaler — o que é a transformação para o feature_range default.
Quem quiser outro feature_range está fora do medido, e o exportador recusa
escrever qualquer arquivo cujas predições discordem do estimador.
O irmão de cada estimador dessas famílias (RidgeClassifier, SGDRegressor,
LinearSVR, DecisionTreeClassifier, RandomForestRegressor,
ExtraTreesRegressor, ExtraTree*) usa a mesma aritmética e é aceito pelo
exportador; quem decide caso a caso é ele, comparando os bytes contra o
scikit-learn antes de escrever.
Medido em Chrome real¶
Artefato Modo A, Pyodide, sem onnxruntime-web em lugar nenhum — um
RandomForest de 12 árvores (4.764 B) e uma LogisticRegression de 6 features
(460 B):
| Medida | Resultado |
|---|---|
| Predição do forest | setosa p=1,00000000 (sklearn: setosa, 1,0) |
| Predição do linear | 0 p=0,99111871 (sklearn: 0,9911187022504708) |
| Frio: download + parse + 1 linha | 6,3 ms |
| Forest, por linha (100 execuções) | mediana ~0,0 ms · p95 0,2 ms |
| Linear, por linha (100 execuções) | mediana ~0,0 ms · p95 0,1 ms |
| Forest, 1.000 linhas de uma vez | 51,8 ms |
| Requests dos modelos, com 200 predições | 1 por modelo |
A paridade é medida contra o sklearn, não contra nós mesmos
Os doze .tmc da suíte são escritos pelo publicador do formato e ao lado
deles fica o que o scikit-learn respondeu para as mesmas linhas
(tests/fixtures/compact/). A armadilha que isso pega: sklearn.tree
converte a entrada para float32 antes de percorrer, então um limiar e uma
entrada um passo de float64 acima dele comparam iguais e vão para a
esquerda. Comparar em float64 manda a linha para a direita — e não é
arredondamento: cada fixture de árvore carrega uma linha de fronteira
exatamente sobre um limiar, e nela o forest responde versicolor p=0,666667
em float32 (o que o sklearn respondeu) contra virginica p=0,833333 em
float64, e o regressor de árvore 0,980769 contra 0,541667.
Modos A e B
O leitor é Python, e o Modo C serve um conjunto fechado de módulos: ele recusa o import no build, nomeando os módulos que aceita.
app.py:7: import from 'tempestweb.tabular' is not supported
(only tempest_core, `tempestweb.components` and `tempestweb.native`)
A mensagem não diz qual modo tem a capacidade — quem faz isso é a recusa de
capability (native.*), não a de pacote. Para inferência tabular no Modo
C, o caminho é chamar uma API do servidor.
Erros nomeados¶
| Situação | Erro |
|---|---|
| Feature faltando (ou renomeada) | MissingFeatureError, com o que faltou e o que veio |
| Feature que o modelo não conhece | UnknownFeatureError (desligue com strict=False) |
| Manifesto sem features, ou com feature repetida | ManifestError |
| Valor que não é número | ValueError nomeando a feature |
| Modelo respondeu algo ilegível | PredictionError |
| Export com ZipMap | NativeError("unsupported_output") dizendo como reexportar |
.tmc que este leitor não entende |
CompactFormatError, nomeando o estimador do header |
| Manifesto com número de features diferente do modelo | ManifestError, com os dois números |
O CompactFormatError é o que recusa um .tmc em vez de predizer sobre ele:
magic errado, versão de layout que este leitor não implementa, seção que o header
prometeu e o arquivo não carrega, kind/link fora do formato — e todo número
que o header declara sobre a própria forma, cruzado com os bytes ao lado
(coef == n_features × n_outputs, intercept == n_outputs,
tree_offset == n_trees + 1, offset e scale cobrindo cada feature,
n_outputs == 1 para regressão). Header que discorda das próprias seções não
falhava: ele predizia, sobre o que a indexação alcançasse.
Fora de escopo nesta versão¶
- Gradient boosting no
CompactPredictor— o exportador recusa, e o caminho é oTabularPredictor. - Modo C: o leitor compacto é Python, então segue Modos A e B como o
TabularPredictor. - Treinar no browser. Isto é inferência.
Recap¶
- O manifesto é o que impede a predição silenciosamente errada: ele declara quais features o modelo espera e em que ordem.
predict(row)aceita a linha em qualquer ordem;predict_many(rows)roda tudo numa execução só.zipmap=Falseno export é obrigatório, e esquecer dá erro que diz isso.- Treinar e exportar é passo de build em venv descartável, nunca dependência.
- Medido em Chrome real: idêntico ao sklearn até 6e-08, e 0,1 ms por inferência de 3 linhas.
- O runtime é que pesa: 13,96 MB de
onnxruntime-webpara um modelo de 660 bytes. Carregueort.wasm.min.jse economize 13,8 MB crus. CompactPredictordispensa o runtime para modelo linear e ensemble de árvore: 6,3 ms do frio à primeira predição, p95 de 0,2 ms por linha, e o.tmccarrega o próprio manifesto.