- Classificador de NCM pela descrição do produto — BERTimbau + ONNX
- O que este modelo faz
- Exemplos reais: da descrição na nota ao código na tabela
- Para quem é
- Por que isso importa agora (reforma tributária e a NF-e de 2026)
- Como usar
- Métricas
- Calibração e abstenção — o diferencial
- Dados de treino
- Limitações
- Latência (CPU, medição real)
- Perguntas frequentes
- Licença
- Disclaimer
- English summary
- O que este modelo faz
Classificador de NCM pela descrição do produto — BERTimbau + ONNX
Ferramenta de apoio · caráter orientativo · a responsabilidade pela classificação é do contribuinte/contador.
Este modelo é um auxiliar de triagem. Quando não tem confiança suficiente, ele se abstém — isso é um recurso de segurança, não uma falha. Nenhuma saída deste modelo substitui o parecer de um contador habilitado (CFC Res. 560/83).
O que este modelo faz
Ele recebe a descrição de um item de nota fiscal do jeito que ela realmente sai
do ERP — telegráfica, abreviada, sem pontuação, tipo "PARAF SEXT ZINC M8X40 DIN933" — e sugere os 3 códigos NCM de 8 dígitos mais prováveis, cada um
com uma confiança calibrada. Quando a confiança não basta para uma decisão
automática, o modelo sinaliza abstenção: o top-3 continua visível, mas
marcado como "isto precisa de revisão humana".
O fluxo completo, na prática:
descrição do item na NF-e → código NCM sugerido → texto oficial da tabela NCM
"PARAF SEXT ZINC M8X40..." 73181500 (98,3%) "Parafusos, pinos ou pernos,
roscados... de ferro ou aço"
Por trás desse resultado: neuralmind/bert-large-portuguese-cased (BERTimbau Large) com uma
cabeça de classificação linear sobre 9.748 códigos NCM de 8 dígitos — 344,4M
de parâmetros no total (24 camadas, hidden 1024; contagem feita no checkpoint).
O ajuste fino foi feito exclusivamente com dados públicos e processamento local
(detalhes na seção de dados).
Este modelo não roda em llama.cpp nem tem versão GGUF. Ele é um encoder
com cabeça de classificação, não um decoder que gera texto — o caminho de
produção é o ONNX Runtime (veja "Como usar"). Essa escolha de arquitetura
foi medida em uma comparação direta entre as duas famílias (um bake-off),
não assumida: para classificar itens em lote na CPU
de um escritório contábil, o encoder acertou ~4x mais e custou de 5 a 40x menos
por item do que o decoder equivalente.
Exemplos reais: da descrição na nota ao código na tabela
Todos os exemplos abaixo são saídas reais do pacote publicado neste repositório (nada foi editado), cruzadas com a tabela NCM vigente (Resolução Gecex nº 812/2025). A terceira coluna é o texto oficial da tabela para o código sugerido — posição e subitem combinados, para leitura:
| Descrição do item (como vem na NF-e) | NCM sugerido | Confiança | O que diz a tabela NCM oficial |
|---|---|---|---|
PARAF SEXT ZINC M8X40 DIN933 |
7318.15.00 | 98,3% | Parafusos, pinos ou pernos, roscados […] de ferro fundido, ferro ou aço → Outros parafusos e pinos ou pernos, mesmo com as porcas e arruelas |
TONER COMPAT HP CF283A PRETO |
8443.99.33 | 99,7% | Impressoras — partes e acessórios → Cartuchos de revelador (toners) |
CANETA ESF AZUL CX 50 |
9608.10.00 | 99,2% | Canetas esferográficas |
OLEO SOJA REFINADO PET 900ML |
1507.90.11 | 97,3% | Óleo de soja, refinado → Em recipientes com capacidade inferior ou igual a 5 l |
MORTADELA FATIADA KG |
1601.00.00 | 95,7% | Enchidos e produtos semelhantes, de carne […]; preparações alimentícias à base desses produtos |
PNEU 175/70 R13 82T |
4011.10.00 | 93,6% | Pneumáticos novos, de borracha → Do tipo utilizado em automóveis de passageiros |
CIMENTO PORTLAND CP II Z 32 SC 50KG |
2523.29.10 | 67,0% | Cimentos Portland → Cimento comum |
Repare no cimento: 67% em vez de 98%. A confiança é calibrada — ela cai quando a descrição deixa margem real de dúvida (o segundo candidato, 2523.29.90 "Outros", leva 12,2%). Esse número não é decorativo: ele foi ajustado para que "67%" signifique, na prática, acertar cerca de 67% das vezes.
E quando o modelo não sabe? Abstenção em ação
ARROZ BRANCO TIPO 1 5KG
→ ABSTEVE-SE (nenhum candidato passou dos pisos de decisão)
top-3 exibido mesmo assim:
1006.30.19 (19,8%) — arroz semibranqueado ou branqueado, parboilizado, outros
1006.10.92 (15,4%) — arroz com casca, não parboilizado
1006.20.20 (13,8%) — arroz descascado, não parboilizado
E ele tem razão em hesitar: a tabela NCM separa o arroz por beneficiamento (com casca / descascado / branqueado, parboilizado ou não) — e "ARROZ BRANCO TIPO 1 5KG" não diz se é parboilizado. Nenhum classificador honesto consegue cravar a 8ª casa com essa informação; a resposta certa é escalar para quem tem o produto na mão. É exatamente isso que a abstenção faz.
Outro caso real, mais sutil:
PARACETAMOL 750MG C/20 COMP
→ ABSTEVE-SE (confiança do 1º candidato abaixo do piso de 55%)
3004.90.45 (52,7%) — medicamentos em doses, p/ venda a retalho: paracetamol
3003.90.55 (27,4%) — medicamentos NÃO acondicionados p/ venda a retalho: paracetamol
O primeiro candidato é o código certo para uma caixa de 20 comprimidos — mas a disputa entre 30.04 (acondicionado para venda) e 30.03 (a granel) depende de um detalhe que a descrição não crava, e o modelo prefere entregar a dúvida ao contador em vez de escondê-la atrás de um chute.
Para quem é
Contadores e escritórios contábeis, integradores de ERP (Omie, Bling, Tiny) e sellers/marketplaces que precisam classificar catálogos inteiros em NCM — seja como triagem antes da revisão humana, seja para conferir divergências entre o NCM informado e a descrição do produto.
Por que isso importa agora (reforma tributária e a NF-e de 2026)
A partir de 03/08/2026, a NF-e de Regime Normal passa a ser rejeitada
quando não trouxer IBS/CBS/cClassTrib válidos (NT 2025.002 v1.40). Em
04/01/2027, a mesma exigência chega ao Simples Nacional e ao MEI. O
cClassTrib depende do NCM correto — um NCM errado contamina toda a tributação
da reforma dali para frente.
Este modelo ataca a parte estável do problema: descrição → NCM. O mapeamento
NCM → cClassTrib/CST fica em tabelas oficiais versionadas, fora do modelo, de
propósito: essas tabelas mudam com frequência (a IT 2025.002 já está na v1.50) e
não faz sentido retreinar um modelo a cada portaria.
Como usar
1. pip install ncm-classificador (recomendado — o caminho testado de ponta a ponta)
O pacote pip (ncm-classificador, wheel de 10,5KB, sem dependência de torch)
já traz a tokenização, a aplicação de temperatura e a regra de abstenção —
exatamente as mesmas usadas para medir os números desta página. É o caminho
oficial do produto, não uma reimplementação.
pip install ncm-classificador
# baixe o pacote de inferência deste repositório (modelo.onnx + tokenizer/ +
# labels.json + inferencia.json + manifest.json) e aponte para ele:
export NCM_PACOTE=/caminho/para/pacote-baixado-do-hf
ncm classificar "PARAF SEXT ZINC M8X40 DIN933"
Saída real (medida em 2026-07-17, com o pacote deste repositório):
NCM para: PARAF SEXT ZINC M8X40 DIN933
┌───────────────┬─────────────────────┐
│ candidato NCM │ confiança calibrada │
├───────────────┼─────────────────────┤
│ 73181500 │ 98.3% │
│ 73181100 │ 0.8% │
│ 86079900 │ 0.1% │
└───────────────┴─────────────────────┘
Ferramenta de apoio · caráter orientativo · a responsabilidade pela
classificação é do contribuinte/contador.
O mesmo pacote traz ncm lote arquivo.csv (classificação em lote) e
ncm servir (API REST auto-hospedada em FastAPI, com API key e rate limit).
A documentação completa está na página do pacote no PyPI:
https://pypi.org/project/ncm-classificador/ (código sob licença MIT).
2. ONNX Runtime direto (para quem já tem pipeline próprio)
O grafo devolve logits crus. A calibração (temperatura e pisos de abstenção)
é política de produto e mora em inferencia.json, não dentro do grafo — assim a
calibração pode ser reajustada sem reexportar o modelo.
import json
import numpy as np
import onnxruntime as ort
from tokenizers import Tokenizer
# arquivos deste repositório: modelo.onnx, tokenizer/tokenizer.json,
# labels.json (lista de 9.748 códigos NCM, na ORDEM dos logits),
# inferencia.json (temperatura + pisos de abstenção)
sessao = ort.InferenceSession("modelo.onnx", providers=["CPUExecutionProvider"])
tok = Tokenizer.from_file("tokenizer/tokenizer.json")
labels = json.load(open("labels.json", encoding="utf-8"))
config = json.load(open("inferencia.json", encoding="utf-8"))
enc = tok.encode("PARAF SEXT ZINC M8X40 DIN933")
# trunque/pad para config["max_len"] (128) — o pacote pip faz isso por você
(logits,) = sessao.run(
["logits"],
{
"input_ids": np.array([enc.ids], dtype=np.int64),
"attention_mask": np.array([enc.attention_mask], dtype=np.int64),
"token_type_ids": np.array([enc.type_ids], dtype=np.int64),
},
)
temperatura = config["temperatura"] # 0,786
probs = np.exp(logits / temperatura) / np.exp(logits / temperatura).sum()
top3 = np.argsort(-probs[0])[:3]
for i in top3:
print(labels[i], f"{probs[0][i]:.1%}")
3. transformers (acesso direto aos pesos — para pesquisa, não para produto)
O checkpoint também está disponível em safetensors/transformers, para quem
quer inspecionar ou retreinar os pesos. Atenção: o id2label do
config.json usa placeholders genéricos (LABEL_0, LABEL_1...) — o
mapeamento real para códigos NCM está em labels.json (na mesma ordem dos
logits), e a confiança só é calibrada depois de dividir os logits pela
temperatura de inferencia.json. Para uso em produto, prefira o caminho
ONNX/pip acima, que já faz tudo isso.
import json
import torch
from transformers import AutoModelForSequenceClassification, AutoTokenizer
tok = AutoTokenizer.from_pretrained("DominuZ/ncm-classificador-bertimbau")
model = AutoModelForSequenceClassification.from_pretrained("DominuZ/ncm-classificador-bertimbau")
labels = json.load(open("labels.json", encoding="utf-8"))
inputs = tok("PARAF SEXT ZINC M8X40 DIN933", return_tensors="pt", truncation=True, max_length=128)
with torch.no_grad():
logits = model(**inputs).logits
probs = torch.softmax(logits / 0.786, dim=-1) # temperatura de inferencia.json
top3 = torch.topk(probs, k=3)
for score, idx in zip(top3.values[0], top3.indices[0]):
print(labels[idx], f"{score:.1%}")
Métricas
Três recortes diferentes, porque um número só esconderia mais do que revela neste problema:
| Recorte | acc@2 | acc@4 | acc@6 | acc@8 | ECE |
|---|---|---|---|---|---|
| Validação agregada (44.257 itens, ~74% sintético) | 89,5% | 72,3% | 62,5% | 57,5% | 0,019 |
| Validação — só dados reais (11.446 itens, 100% consenso multi-emissor, nada sintético) | — | — | — | 76,5% | — |
| RFB-bench (4.944 decisões vinculantes reais da Receita, prosa jurídica formal) | 55,2% | 28,6% | 14,2% | 8,9% | — |
acc@N = fração de itens em que os N primeiros dígitos do código previsto
coincidem com os do código correto (acc@2 = capítulo certo, acc@4 = posição,
acc@6 = subposição, acc@8 = código completo). É a métrica adequada porque a
hierarquia NCM é aninhada: acertar capítulo e posição já reduz o trabalho de
revisão mesmo quando a 8ª casa erra. (Não confundir com top-N: o top-3 que o
produto exibe é outra coisa — os 3 códigos completos mais prováveis.) ECE =
Expected Calibration Error após ajuste de temperatura (T=0,786) — quanto mais
perto de zero, mais a confiança informada corresponde à taxa de acerto real.
Por que o RFB-bench é tão mais baixo — e por que publicamos o número assim mesmo: o modelo foi treinado com descrições de item de NF-e — telegráficas, abreviadas, sem sujeito nem verbo. O RFB-bench vem de Soluções de Consulta da Receita Federal: prosa jurídica longa e formal, outro universo de texto. É um desvio de distribuição clássico, não um defeito da arquitetura — qualquer classificador treinado no estilo NF-e apanha nesse estilo de texto. Já tentamos encurtar essa distância com paráfrases formais no treino: o acerto de capítulo melhorou (+10pp no acc@2), mas não se converteu em acerto do código completo. Preferimos publicar o número baixo a escondê-lo: a proposta deste projeto é se destacar pela calibração e pela abstenção corretas, e isso começa por divulgar as métricas como elas são. (Nota: os rótulos do RFB-bench vêm de atos oficiais da Receita, mas a revisão humana par a par do conjunto ainda está em andamento — o dataset publicado indica isso item a item.)
Calibração e abstenção — o diferencial
A confiança que o modelo informa é calibrada: quando ele diz "98,3%", isso fica próximo da taxa de acerto observada para previsões naquela faixa (ECE 0,019 depois do ajuste de temperatura, contra 0,153 antes). E quando a confiança não basta, ele se abstém em vez de chutar:
- Pisos de abstenção (provisórios): confiança do 1º candidato abaixo de 55% ou margem entre o 1º e o 2º candidato abaixo de 10 pontos percentuais.
- Taxa de abstenção observada na validação: 48,4%.
- Predição conforme (RAPS, α=0,05): conjunto de candidatos com 95,0% de cobertura garantida e tamanho médio de 290 códigos — um número que documenta a fronteira desta primeira geração (garantir cobertura alta num espaço de 9.748 códigos ainda exige um conjunto grande; um modelo mais forte encolhe esse conjunto sem mexer no α).
- Verificação manual (20 itens): quando o modelo respondeu, o top-1 estava correto em todos os casos verificados; os erros observados caíram todos do lado da abstenção — o contrato "na dúvida, escale a um humano" se sustentou na amostra.
A abstenção não esconde o top-3 — ele continua visível, apenas marcado como insuficiente para decisão automática. O produto aponta o risco; quem decide é o contador.
Dados de treino
100% dados públicos e processamento local — nenhuma API paga, nenhum dado
raspado de terceiros. Composição real do conjunto de treino do modelo
publicado aqui (treino_d.parquet, 653.348 exemplos, 9.748 códigos — números
lidos do arquivo, não estimados):
| origem | exemplos | descrição |
|---|---|---|
consenso |
398.230 | descrições reais de item de NF-e, rotuladas por consenso multi-emissor: um grupo de descrições equivalentes só recebe um NCM quando ≥3 emissores distintos concordam com ≥70% dos votos — sobre um corpus de 28.035.593 itens do Portal da Transparência (2021-10 a 2026-06) |
quarentena_v1 |
154.801 | descrições reais adicionais, mineradas dos grupos que não atingiram o quórum do consenso, aceitas apenas com unanimidade total de votos como compensação pelo quórum menor (reforço para códigos com <21 exemplos) |
sintese_v1 |
74.523 | descrições sintéticas geradas e verificadas por dois modelos locais independentes (gerador de 4B / verificador de 9B), para códigos sem exemplos reais suficientes |
sintese_nesh_v1 |
25.794 | descrições sintéticas guiadas pelo texto oficial da NESH (Notas Explicativas do Sistema Harmonizado, IN RFB 2.169/2023), com contraste explícito contra posições NCM vizinhas — reforço direcionado aos códigos que continuavam com poucos exemplos após a síntese v1 |
Todo o pipeline (consenso, mineração, síntese) rodou em uma única GPU de
16GB, sem nenhuma API paga — os modelos de síntese são locais, servidos via
llama.cpp. A validação (44.257 itens) usa a mesma mistura; o RFB-bench (4.944
pares) ficou totalmente fora do treino, com guarda de anticontaminação
verificada em código.
LGPD: dos itens de NF-e processados, usamos exclusivamente (descrição, NCM). Identificadores de emitente (CNPJ/CPF) são descartados na fronteira do
ponto de entrada do pipeline — pseudonimizados (hash truncado) apenas o suficiente para o voto de
consenso contar emissores distintos, nunca guardados de forma reversível, e
removidos de vez antes da rotulagem. Nenhum dado de destinatário/comprador entra
no pipeline.
Limitações
- Ambiguidade que a descrição não resolve: boa parte das abstenções não é fraqueza do modelo, é informação que falta na nota — o exemplo do arroz acima (a tabela exige saber se é parboilizado; a descrição não diz). Nesses casos, o caminho certo é o fluxo com revisão humana — e é para isso que a abstenção existe.
- Erros confiantes existem. Exemplo real:
NOTEBOOK 15,6 I5 8GB 256GB SSDrecebeu 8443.32.31 (impressora de jato de tinta) com 56,6% — logo acima do piso de abstenção de 55%, então o modelo respondeu, e respondeu errado (os candidatos 2 e 3, ambos da posição 84.71 de notebooks, somavam 35%). É o tipo de caso que os pisos provisórios ainda deixam passar; por isso o top-3 completo é sempre exibido e a decisão final é humana. - Códigos raros aprendem menos: 1.654 dos 9.748 códigos NCM seguem com poucos exemplos de treino mesmo após duas rodadas de geração sintética — é o teto do que a síntese consegue produzir com o material atual. Para esses códigos raros, espere confiança menor e mais abstenção. O próximo degrau é dado real novo (mais meses do Portal da Transparência, outras fontes públicas), não mais síntese sobre o mesmo material.
- Texto formal (RFB-bench) é uma fronteira conhecida e, por ora, não perseguida — veja a seção de métricas. O ganho de capítulo/posição via paráfrase (+10pp no acc@2) não se converteu em acerto do código completo.
- A validação agregada é ~74% sintética (32.811 de 44.257 itens) — o 57,5% de acc@8 carrega esse viés; o recorte 100% real (76,5%) é a referência mais honesta para desempenho em produção sobre texto de NF-e.
- A tabela NCM oficial muda (novos códigos, extinções, renumerações). O
modelo aprende o problema estável (descrição → NCM); a tabela vigente e o
mapeamento para
cClassTrib/CST vivem em tabelas versionadas fora do modelo. Uma mudança de nomenclatura publicada depois do corte de treino só chega ao modelo com retreino — ou pela atualização da tabela no produto, que é trocável sem mexer no modelo. - Os pisos de abstenção (confiança 0,55 / margem 0,10) são provisórios — calibrados na validação, ainda sem validação contra volume de produção real.
- Formato de exportação: só a variante fp32 (~1,3GB) é distribuída. A quantização INT8 dinâmica foi medida e reprovada (-6,4pp de acc@8, ECE 6,7x pior — a calibração quebra) e não está neste repositório; quantização estática poderá ser avaliada no futuro, com o mesmo critério de paridade.
- Não emite parecer fiscal. É ferramenta de triagem e apoio — nunca a decisão final.
Latência (CPU, medição real)
~55ms por item em lote de 1 (incluindo tokenização; onnxruntime com
CPUExecutionProvider) — 100 itens da validação classificados em 6,9s no
total, já contando o tempo de carregar o modelo. Lotes maiores amortizam
melhor o custo fixo de carregar o modelo na memória. Não precisa de GPU.
Perguntas frequentes
Como encontrar o código NCM correto a partir da descrição do produto? O caminho oficial é a tabela NCM (Siscomex) com as regras gerais de interpretação do Sistema Harmonizado e as notas da NESH — e, em caso de dúvida real, uma Solução de Consulta à Receita Federal. Este modelo automatiza a primeira etapa desse funil: dado o texto livre do item, ele aponta os 3 códigos mais prováveis com confiança calibrada, para o contador revisar apenas o que merece revisão.
O que acontece se o NCM da nota fiscal estiver errado?
Tributação errada (a NCM determina IPI, II e, com a reforma, o cClassTrib de
IBS/CBS), risco de autuação e multa, e — a partir de 03/08/2026 — rejeição da
NF-e quando o cClassTrib derivado for inválido. Classificar bem deixou de ser
detalhe de compliance para virar pré-requisito de emissão.
Este modelo substitui o contador? Não — e foi desenhado para não substituir. Ele se abstém quando não tem confiança, exibe o top-3 mesmo quando se abstém e carrega o disclaimer em toda saída. A responsabilidade pela classificação fiscal é do contribuinte e de seu contador (CFC Res. 560/83); o modelo faz a triagem, o humano decide.
Roda offline? Preciso de GPU?
Roda 100% offline e em CPU (~55ms por item). O pacote de inferência é
autocontido: modelo.onnx + tokenizer + labels.json + inferencia.json,
com integridade conferida por SHA-256 antes de responder.
Tem versão GGUF para llama.cpp / LM Studio / Ollama? Não, e não vai ter: a arquitetura é um encoder com cabeça de classificação (não um modelo generativo). O caminho de produção é ONNX Runtime — mais leve e mais barato para este problema.
Licença
Os pesos ajustados publicados neste repositório (checkpoint + cabeça de 9.748
classes) são distribuídos sob CC BY 4.0 — use, redistribua e derive à
vontade, com atribuição a este repositório. O modelo base
(neuralmind/bert-large-portuguese-cased, BERTimbau) é distribuído sob MIT
pelos autores originais (NeuralMind), e a atribuição a eles permanece.
Disclaimer
Ferramenta de apoio · caráter orientativo · a responsabilidade pela classificação é do contribuinte/contador.
Este modelo assiste o trabalho do contador — nunca o substitui (CFC Res. 560/83). Ele se abstém quando não tem confiança suficiente; abstenção não é defeito, é o modelo dizendo "isto precisa de revisão humana".
English summary
What: A Portuguese-language classifier (BERTimbau Large + a 9,748-class
head, 344.4M parameters) that maps free-text Brazilian tax-invoice (NF-e) item
descriptions to 8-digit NCM codes (Brazil's harmonized-system-derived tax
classification), returning a top-3 with calibrated confidence and explicit
abstention when uncertain.
Why now: starting 2026-08-03, Brazilian NF-e invoices under the standard
tax regime get rejected without valid IBS/CBS tax-classification codes
(cClassTrib), which in turn depend on a correct NCM code — this model targets
that upstream, stable sub-problem.
Numbers: aggregate validation (44,257 items, ~74% synthetic) acc@8 57.5%, acc@2 89.5%, ECE 0.019 post-temperature; on the real-data-only validation cut (11,446 items) acc@8 76.5%; on an out-of-distribution benchmark of 4,944 real binding tax rulings from Brazil's federal tax authority (formal legal prose vs. the telegraphic invoice-item style seen in training) acc@8 drops to 8.9% (acc@2 55.2%) — reported openly as an honest distribution-shift result, not hidden. Abstention rate 48.4% (provisional thresholds). Trained 100% on local/public data (public government feed + locally-verified synthetic data, cross-model generator/verifier), on a single 16GB consumer GPU, with no paid third-party APIs. Not GGUF-compatible (it's an encoder + classification head, not a decoder) — the deployment rail is ONNX Runtime (fp32; dynamic INT8 quantization was measured and rejected for distribution — it broke calibration). This is a support tool, not a substitute for professional accounting judgment.
- Downloads last month
- 31
Dataset used to train DominuZ/ncm-classificador-bertimbau
Evaluation results
- acc@2 (capítulo — 2 primeiros dígitos do NCM corretos) on Validação interna (44.257 itens, ~74% sintético)self-reported0.895
- acc@4 (posição — 4 dígitos) on Validação interna (44.257 itens, ~74% sintético)self-reported0.724
- acc@6 (subposição — 6 dígitos) on Validação interna (44.257 itens, ~74% sintético)self-reported0.625
- acc@8 (código NCM completo de 8 dígitos exato) on Validação interna (44.257 itens, ~74% sintético)self-reported0.575
- ECE pós-temperatura (T=0,786) on Validação interna (44.257 itens, ~74% sintético)self-reported0.019
- acc@8 on Validação — subconjunto 100% real (consenso multi-emissor, 11.446 itens)self-reported0.765
- acc@2 on Soluções de Consulta COANA/COSIT (4.944 pares, pendente de revisão humana)self-reported0.552
- acc@4 on Soluções de Consulta COANA/COSIT (4.944 pares, pendente de revisão humana)self-reported0.286