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 SSD recebeu 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
Safetensors
Model size
0.3B params
Tensor type
F32
·
Inference Providers NEW
This model isn't deployed by any Inference Provider. 🙋 Ask for provider support

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-reported
    0.895
  • acc@4 (posição — 4 dígitos) on Validação interna (44.257 itens, ~74% sintético)
    self-reported
    0.724
  • acc@6 (subposição — 6 dígitos) on Validação interna (44.257 itens, ~74% sintético)
    self-reported
    0.625
  • acc@8 (código NCM completo de 8 dígitos exato) on Validação interna (44.257 itens, ~74% sintético)
    self-reported
    0.575
  • ECE pós-temperatura (T=0,786) on Validação interna (44.257 itens, ~74% sintético)
    self-reported
    0.019
  • acc@8 on Validação — subconjunto 100% real (consenso multi-emissor, 11.446 itens)
    self-reported
    0.765
  • acc@2 on Soluções de Consulta COANA/COSIT (4.944 pares, pendente de revisão humana)
    self-reported
    0.552
  • acc@4 on Soluções de Consulta COANA/COSIT (4.944 pares, pendente de revisão humana)
    self-reported
    0.286