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 de execução (nada foi editado),
cruzadas com a tabela NCM vigente (Resolução Gecex nº 812/2025). Foram
capturados com uma versão anterior deste pacote (corrida d); o fluxo e o
formato da saída são idênticos na versão atual, e os números de confiança
individuais podem oscilar alguns décimos entre versões do modelo — o texto ao
redor de cada exemplo continua valendo. 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.
bash
1pip install ncm-classificador
2# baixe o pacote de inferência deste repositório (modelo.onnx + tokenizer/ +3# labels.json + inferencia.json + manifest.json) e aponte para ele:4exportNCM_PACOTE=/caminho/para/pacote-baixado-do-hf
5ncm classificar "PARAF SEXT ZINC M8X40 DIN933"
Saída real (medida em 2026-07-17, com uma versão anterior deste pacote —
corrida d; a versão atual segue o mesmo formato):
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.
python
1import json
2import numpy as np
3import onnxruntime as ort
4from tokenizers import Tokenizer
56# arquivos deste repositório: modelo.onnx, tokenizer/tokenizer.json,7# labels.json (lista de 9.748 códigos NCM, na ORDEM dos logits),8# inferencia.json (temperatura + pisos de abstenção)9sessao = ort.InferenceSession("modelo.onnx", providers=["CPUExecutionProvider"])10tok = Tokenizer.from_file("tokenizer/tokenizer.json")11labels = json.load(open("labels.json", encoding="utf-8"))12config = json.load(open("inferencia.json", encoding="utf-8"))1314enc = tok.encode("PARAF SEXT ZINC M8X40 DIN933")15# trunque/pad para config["max_len"] (128) — o pacote pip faz isso por você16(logits,)= sessao.run(17["logits"],18{19"input_ids": np.array([enc.ids], dtype=np.int64),20"attention_mask": np.array([enc.attention_mask], dtype=np.int64),21"token_type_ids": np.array([enc.type_ids], dtype=np.int64),22},23)24temperatura = config["temperatura"]# 0,78825probs = np.exp(logits / temperatura)/ np.exp(logits / temperatura).sum()26top3 = np.argsort(-probs[0])[:3]27for i in top3:28print(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.
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,4%
72,3%
62,4%
57,3%
0,027
Validação — só dados reais (11.446 itens, 100% consenso multi-emissor, nada sintético)
—
—
—
76,3%
—
RFB-bench (4.944 decisões vinculantes reais da Receita, prosa jurídica formal)
60,1%
38,8%
22,2%
16,7%
—
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,788) — quanto mais
perto de zero, mais a confiança informada corresponde à taxa de acerto real.
Por que o RFB-bench ainda é mais baixo que o val — e por que publicamos o
número assim mesmo: o modelo foi treinado majoritariamente 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. É desvio de distribuição clássico,
não defeito de arquitetura. A boa notícia é que essa distância diminuiu bastante
depois que passamos a treinar com uma fatia de dado real nesse mesmo registro
formal — a lista consolidada de bens da Camex/LESSIN (pública, descrições
técnicas de ex-tarifários escritas pelo próprio governo). O acc@8 no
RFB-bench quase dobrou (8,9% → 16,7%) e o acc@2 subiu 4,9pp (55,2% → 60,1%),
sem custar acurácia na validação com dados reais de NF-e (76,3%, dentro do
ruído dos 76,5% anteriores). Antes disso, paráfrases formais sintéticas
tinham melhorado só o capítulo (+10pp no acc@2) sem tocar no código completo
— foi dado real, não paráfrase, que moveu a régua no acc@8. Ainda assim o
a distância persiste: o RFB-bench continua bem abaixo do desempenho em texto de NF-e,
porque a maior parte do treino segue sendo o registro telegráfico da nota
fiscal. Preferimos publicar o número como ele é 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,027
depois do ajuste de temperatura, contra 0,155 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,8%.
Predição conforme (RAPS, α=0,05): conjunto de candidatos com 95,1% de
cobertura garantida e tamanho médio de 312 códigos — um número que documenta
a fronteira desta 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.
Resposta parcial de posição — quando a folha não fecha, mas a posição fecha
Nem toda abstenção é igual. Às vezes o modelo não tem confiança suficiente
para cravar os 8 dígitos, mas está bastante seguro sobre os 4 primeiros
(a posição da NCM — o nível que já reduz o universo de folhas candidatas para
o contador escolher). Medimos essa situação separadamente, com um piso de
confiança próprio calibrado só para a posição: em dados reais, quando a soma
de probabilidade da posição vencedora passa desse piso, ela acerta a posição
correta em 93,8% dos casos (321 itens, barra pré-registrada era 89,9% —
ver ADR-026/ADR-027 em docs/DECISIONS.md). Isso acontece em 5,6% das
consultas que, de outra forma, seriam abstenções mudas — nesses casos, a
resposta ganha um campo extra: resposta_parcial.
Saída real de execução (pacote f-1, ncm classificar ... --json):
{"descricao": "ARROZ(CLASSIFICAÇÂO SEM CARACTERÍSTICAS)", "top3": [{"ncm8": "10062020", "confianca": 0.301446}, {"ncm8": "10061092", "confianca": 0.141093}, {"ncm8": "10061091", "confianca": 0.107488}], "abstem": true, "resposta_parcial": {"nivel": "posicao", "codigo": "1006", "confianca": 0.902203, "candidatas_folha": ["10062020", "10061092", "10061091"], "texto": "Faltam os últimos 4 dígitos — refine entre as folhas candidatas com seu contador."}, "disclaimer": "Ferramenta de apoio · caráter orientativo · a responsabilidade pela classificação é do contribuinte/contador.", "versao_pacote": "f-1"}
Repare que abstem continua true. A parcial não é uma segunda forma de
decisão automática — é o modelo dizendo "não sei o produto exato, mas sei a
família, e isso já corta o trabalho do contador de 9.748 folhas possíveis
para um punhado". A decisão final sobre qual das candidatas_folha é a
correta continua sendo humana.
Com a parcial somada à abstenção de folha normal, a taxa efetiva de consultas
que ficam sem nenhum apoio de código cai de 25,6% para 19,9% em dados
reais — o restante da abstenção ganha pelo menos a posição. Testamos também
uma camada equivalente no nível de capítulo (2 dígitos); ela não bateu a
barra de precisão pré-registrada e ficou de fora desta versão (arquivada em
definitivo pelo ADR-026) — por isso só o nível posição aparece no produto.
O campo resposta_parcial só existe em pacotes de modelo que trazem
piso_posicao no inferencia.json (o f-1 publicado aqui traz); pacotes
mais antigos continuam funcionando normalmente, só que sempre com
resposta_parcial: null.
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_f.parquet, 678.585 exemplos, 9.748 códigos — números
lidos do arquivo, não estimados; as mesmas linhas do conjunto E, com as
descrições da Camex a peso 0,5 no gradiente):
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
camex_v1
25.237
descrições reais de produto vindas da lista consolidada de bens da Camex/LESSIN (pública, atualizada semanalmente) — texto técnico em prosa formal-jurídica, o mesmo registro das Soluções de Consulta da Receita (RFB-bench). É a fonte que fechou boa parte da distância do modelo para texto formal; entra com peso 0,5 no gradiente desta corrida, para não deslocar capacidade demais do registro telegráfico da NF-e (que ainda é o alvo principal do produto)
Todo o pipeline (consenso, mineração, síntese, ingestão da Camex) 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 sem
Camex; o RFB-bench (4.944 pares) ficou totalmente fora do treino, com
guarda de anticontaminação verificada em código (inclusive contra
sobreposição de texto entre a Camex e o RFB-bench).
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) segue sendo uma fronteira, mesmo com o avanço
recente — veja a seção de métricas. Dado real do mesmo registro formal
(Camex/LESSIN) quase dobrou o acc@8 nesse benchmark (8,9% → 16,7%); antes
disso, paráfrases sintéticas só tinham melhorado capítulo/posição (+10pp no
acc@2) sem mexer no código completo. A distância para o desempenho em texto
de NF-e diminuiu, mas continua grande — a maior parte do treino ainda é o
registro telegráfico da nota fiscal.
A validação agregada é ~74% sintética (32.811 de 44.257 itens) — o 57,3%
de acc@8 carrega esse viés; o recorte 100% real (76,3%) é 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 (na corrida atual,
-5,6pp de acc@8 e ECE 3,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)
51,3ms por item em lote de 1 (incluindo tokenização; onnxruntime com
CPUExecutionProvider — medição real do gate de paridade sobre o val inteiro,
44.257 itens). 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 (~51ms 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.3%,
acc@2 89.4%, ECE 0.027 post-temperature; on the real-data-only validation
cut (11,446 items) acc@8 76.3%; 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 is
16.7% (acc@2 60.1%) — still well below in-distribution performance, but
nearly double what earlier checkpoints scored (8.9%), after adding a slice of
real training data written in that same formal register (Camex/LESSIN, a
public government list of product descriptions) at half gradient weight.
Reported openly either way — the proposal here is to stand out on honest
calibration and abstention, not on cherry-picked numbers. Abstention rate
48.8% on aggregate validation, 25.6% on real-data queries. When the model
can't commit to the full 8-digit code but is confident about the 4-digit
position, it now returns a partial position answer alongside the
abstention (measured at 93.8% position accuracy on 321 real items, cutting
real "no answer at all" queries from 25.6% to 19.9%) — the item still counts
as an abstention; the partial answer narrows the candidates for the
accountant, it never decides on its own. 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.