Treinei um modelo local de 344M que classifica NCM de nota fiscal — e ele sabe dizer "não sei"
Quem já integrou ERP conhece o problema: a descrição do item chega como PARAF SEXT ZINC M8X40 DIN933 e alguém precisa decidir qual dos 9.748 códigos NCM de 8 dígitos é o certo. Sempre foi chato; agora vai ficar caro. A partir de 03/08/2026, a NF-e de Regime Normal que não trouxer IBS/CBS/cClassTrib válidos será rejeitada (NT 2025.002) — e o cClassTrib depende de o NCM estar certo.
Passei as últimas semanas treinando um modelo pequeno para esse problema (BERTimbau Large com uma cabeça de classificação, 344M de parâmetros), que roda offline na CPU, sem mandar nada para a nuvem. O modelo, o benchmark e o pacote pip estão publicados — deixo os links no fim. Mas o que eu queria contar aqui são os bastidores, porque as partes mais interessantes do projeto não foram o treino, e sim os problemas de engenharia no caminho até ele.
A dificuldade não estava no modelo, estava no rótulo
Não existe dataset público de descrição → NCM. O que existe é o Portal da Transparência, que publica as notas fiscais das compras do governo federal. Baixei 57 meses de dados (de outubro de 2021 a junho de 2026), o que dá 28 milhões de itens e 5 milhões de descrições distintas, cada uma com o NCM que o emissor declarou na nota.
Só que o emissor erra o NCM o tempo todo — é justamente por isso que uma ferramenta dessas faz sentido. Se eu treinasse direto nesses rótulos, o modelo aprenderia os erros junto.
A solução que encontrei foi um consenso multi-emissor: agrupo as descrições equivalentes e só aceito o rótulo do grupo quando pelo menos 3 emissores diferentes concordam entre si, com 70% ou mais dos votos. A aposta é que emissores independentes até erram bastante, mas raramente erram do mesmo jeito. No fim sobraram cerca de 494 mil descrições reais com rótulo confiável — e uma quarentena de 3,3 milhões de grupos que não alcançaram o quórum.
Vale registrar um detalhe de LGPD: o CNPJ/CPF do emissor vira hash truncado logo na entrada do pipeline (eu só preciso contar emissores distintos, nunca saber quem são) e é descartado de vez antes da rotulagem.
O dia em que 60% do corpus virou um cluster só
Para decidir quais descrições são "equivalentes", usei embeddings e similaridade de cosseno. A literatura sugeria um limiar de 0,90. Apliquei, e um componente conexo engoliu 60% do corpus — bisturi, maionese e pneu, tudo no mesmo cluster. É o fenômeno clássico de percolação: num grafo com 4,7 milhões de nós, basta uma aresta espúria aqui e outra ali para que tudo acabe conectado num bloco só.
Em vez de ajustar no chute, montei um experimento: varri os limiares 0,90, 0,95 e 0,97, cruzados com arestas direcionais versus mutual-kNN. A combinação que venceu foi 0,97 com mutual-kNN (A e B só se conectam se cada um estiver no top-k do outro): o maior cluster caiu para 0,08% do corpus e as famílias ficaram coesas. A lição que levo é que limiar de similaridade não é uma constante universal — ele depende do tamanho do grafo.
A cauda longa: três frentes para alimentar 9.748 classes
O consenso resolve a cabeça da distribuição, mas a NCM tem uma cauda longa enorme: milhares de códigos com meia dúzia de exemplos cada. Fui atacando em três frentes, nesta ordem:
- Mineração da quarentena. Grupos que não tinham quórum, mas onde os votos eram unânimes, entraram no treino das classes mais desfalcadas — mais 155 mil descrições reais.
- Síntese verificada. Um modelo local de 4B gera descrições no estilo NF-e e um de 9B verifica cada uma antes de entrar no treino. Escolhi de propósito dois modelos diferentes para os dois papéis, para que o erro de um não passe despercebido pelo outro. Foram ~107 mil descrições aprovadas, com taxa de aprovação em torno de 32%.
- Síntese guiada pela NESH (as notas explicativas oficiais do Sistema Harmonizado, que extraí do PDF da IN RFB 2.169/2023). Quando medi que 73% dos erros do modelo caíam dentro do capítulo certo, ficou claro que o gargalo era distinguir posições vizinhas — então passei a incluir na geração o texto legal da posição e o contraste com as posições irmãs.
Tudo isso rodou com modelos locais via llama.cpp, numa única RTX 4070 Ti Super de 16GB. Gastei zero em API.
O encoder venceu o decoder com folga
Antes de escolher a arquitetura, coloquei as duas para brigar: BERTimbau (encoder com cabeça de classificação) contra um decoder de 0.6B ajustado para gerar o código como texto. O encoder acertou cerca de 4 vezes mais e custa de 5 a 40 vezes menos por inferência. Quando o rótulo é fechado, fazer o modelo gerar texto é resolver um problema mais difícil do que o necessário, e o preço disso aparece na conta. Na prática, isso também significa que não existe versão GGUF: o caminho de produção é ONNX Runtime, a ~55ms por item na CPU.
Calibração e abstenção — a parte de que mais me orgulho
Os números abaixo são os mesmos que estão publicados no model card:
- 76,5% de acerto exato dos 8 dígitos na validação feita só com dados reais; 89,5% de acerto de capítulo no agregado.
- ECE de 0,019 depois da calibração por temperatura — ou seja, quando o modelo diz "98%", a taxa de acerto observada naquela faixa é de fato ~98%. Confiança sem calibração é enfeite.
- O modelo se abstém em ~48% dos itens: quando a confiança fica abaixo de 55%, ou a margem para o 2º candidato fica menor que 10 pontos, ele declara abertamente que não sabe e pede revisão. O top-3 continua visível.
- No benchmark mais difícil — 4.944 decisões vinculantes reais da Receita, escritas em prosa jurídica — o acerto exato despenca para 8,9%. É desvio de distribuição: o modelo aprendeu "telegrama de ERP", não parecer jurídico. Publiquei o número assim mesmo, porque benchmark que só mostra o lado bom não é benchmark.
Meu exemplo preferido de abstenção é ARROZ BRANCO TIPO 1 5KG. O modelo se recusa a cravar um código — e faz bem: a tabela NCM separa o arroz pelo beneficiamento (parboilizado ou não), e a descrição simplesmente não traz essa informação. Não há classificador honesto que resolva isso; o certo é devolver a dúvida para o humano, e é exatamente o que ele faz.
Links
- Modelo (pesos + pacote ONNX, CC BY 4.0): https://huggingface.co/DominuZ/ncm-classificador-bertimbau
- Benchmark público (RFB-bench, 4.944 pares de atos reais da Receita): https://huggingface.co/datasets/DominuZ/rfb-bench
pip install ncm-classificador(CLI + API self-hosted, MIT): https://pypi.org/project/ncm-classificador/
Faço questão de repetir o aviso: isto é uma ferramenta de apoio, de caráter orientativo — a responsabilidade pela classificação é do contribuinte e do contador (CFC Res. 560/83). O modelo se abstém justamente para nunca fingir uma certeza que não tem.
Feedback técnico é muito bem-vindo, em especial de quem integra ERP ou trabalha com SPED no dia a dia: quero saber onde ele erra nos seus dados.