ncm-classificador
Classificador fiscal NCM: descrição livre de item de NF-e → top-3 códigos NCM com confiança calibrada e abstenção. Roda offline, na sua CPU — nenhum dado sai da máquina.
Ferramenta de apoio · caráter orientativo · a responsabilidade pela classificação é do contribuinte/contador.
Instalação
Via pip (com suporte à API)
pip install ncm-classificador[api]
Sem a flag [api], instala apenas a CLI (sem dependências do servidor FastAPI).
Uso
A CLI resolve o pacote-modelo nesta ordem: --pacote DIR > env NCM_PACOTE > ~/.ncm/pacote.
1. Classificar item único
ncm classificar "PARAFUSO SEXT ZINC M8X40 DIN933"
Saída real (tabela rich — código + confiança calibrada; a descrição textual do NCM
não faz parte do pacote exportado):
NCM para: PARAFUSO SEXT ZINC M8X40 DIN933
┌───────────────┬─────────────────────┐
│ candidato NCM │ confiança calibrada │
├───────────────┼─────────────────────┤
│ 73181500 │ 99.7% │
│ 73181100 │ 0.1% │
│ 86079900 │ 0.0% │
└───────────────┴─────────────────────┘
Ferramenta de apoio · caráter orientativo · a responsabilidade pela
classificação é do contribuinte/contador.
Quando o item abstém, uma linha extra em destaque aparece antes do disclaimer:
ABSTEVE — confiança insuficiente; escale a um contador — ou, quando a posição
fecha mesmo sem fechar a folha, RESPOSTA PARCIAL (ver a seção dedicada mais
abaixo).
Com --json, a saída é uma linha NDJSON (mesmo shape usado pela API — ver adiante).
Repare no campo resposta_parcial: ele está sempre presente, e vem null quando não
há posição confiante o bastante para fechar (é o caso deste item, que já responde
direto na folha):
ncm classificar "PARAFUSO SEXT ZINC M8X40 DIN933" --json
{"descricao": "PARAFUSO SEXT ZINC M8X40 DIN933", "top3": [{"ncm8": "73181500", "confianca": 0.996774}, {"ncm8": "73181100", "confianca": 0.000645}, {"ncm8": "86079900", "confianca": 0.000174}], "abstem": false, "resposta_parcial": null, "disclaimer": "Ferramenta de apoio · caráter orientativo · a responsabilidade pela classificação é do contribuinte/contador.", "versao_pacote": "f-1"}
2. Classificar lote (arquivo CSV)
ncm lote itens.csv > resultado.csv
Não existe --saida: o resultado sai no stdout e é redirecionado com >. (--json
também é aceito e emite uma linha NDJSON por item, no mesmo formato do item 1.)
Entrada (itens.csv, coluna obrigatória descricao):
descricao
PARAFUSO SEXT ZINC M8X40 DIN933
ARROZ(CLASSIFICAÇÂO SEM CARACTERÍSTICAS)
FEIJÃO(CLASSIFICAÇÂO SEM CARACTERÍSTICAS)
OVINO DOMESTICO SANSIBEL - 190KG
Saída real (resultado.csv) — colunas ncm8_N/confianca_N (não ncm_N), campo
abstem (não abstencao), e duas colunas novas no fim, nivel_parcial/codigo_parcial
(script que só lê abstem continua funcionando sem alteração):
descricao,ncm8_1,confianca_1,ncm8_2,confianca_2,ncm8_3,confianca_3,abstem,nivel_parcial,codigo_parcial
PARAFUSO SEXT ZINC M8X40 DIN933,73181500,0.996774,73181100,0.000645,86079900,0.000174,false,,
ARROZ(CLASSIFICAÇÂO SEM CARACTERÍSTICAS),10062020,0.301446,10061092,0.141093,10061091,0.107488,true,posicao,1006
FEIJÃO(CLASSIFICAÇÂO SEM CARACTERÍSTICAS),07082000,0.368774,07133399,0.191740,07139090,0.065303,true,,
OVINO DOMESTICO SANSIBEL - 190KG,01041019,0.492764,01042010,0.337461,01041090,0.052320,true,posicao,0104
# Ferramenta de apoio · caráter orientativo · a responsabilidade pela classificação é do contribuinte/contador.
As três últimas linhas abstêm (abstem=true) — a saída real, não um exemplo forjado.
O ARROZ e o OVINO fecham a posição (nivel_parcial=posicao, com o código de 4 dígitos
em codigo_parcial); o FEIJÃO abstém sem apoio nenhum, e as duas colunas ficam vazias.
3. Consultar informações do modelo
ncm info
Saída real:
Pacote de inferência NCM
┌──────────────────────────┬────────────────────────────────────────┐
│ campo │ valor │
├──────────────────────────┼────────────────────────────────────────┤
│ versão do pacote │ f-1 │
│ modelo base │ neuralmind/bert-large-portuguese-cased │
│ corrida / data de export │ f / 2026-07-18 │
│ temperatura (T) │ 0.788 │
│ pisos de abstenção │ conf 0.55 · margem 0.1 │
│ classes (folhas NCM) │ 9748 │
│ integridade sha256 │ ok (conferida ao carregar) │
└──────────────────────────┴────────────────────────────────────────┘
Ferramenta de apoio · caráter orientativo · a responsabilidade pela
classificação é do contribuinte/contador.
ncm info mostra proveniência e configuração de calibração do pacote carregado — não
expõe métricas de acurácia/ECE do conjunto de validação (essas vivem nos artefatos de
avaliação do treino, não no pacote de produto).
Resposta parcial de posição
Quando o modelo não tem confiança para fechar os 8 dígitos, mas está bastante seguro
sobre os 4 primeiros (a posição da NCM), ele devolve um apoio extra em vez de abstenção
muda: o campo resposta_parcial. Isso só acontece com pacotes de modelo que trazem
piso_posicao no inferencia.json — o f-1 do Hugging Face traz; pacotes mais antigos
(como o d-1) continuam funcionando normalmente, só que sempre com resposta_parcial: null.
abstem continua true nesse caso — a posição não é uma segunda forma de decisão
automática, é o modelo estreitando o universo de folhas candidatas para o contador
escolher entre elas. Saída real (--json, mesmo shape usado pela API):
{"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"}
Na CLI interativa, isso aparece como RESPOSTA PARCIAL (destacada em ciano) em vez do
ABSTEVE amarelo — o top-3 continua exibido do mesmo jeito nos dois casos.
No ncm lote, o mesmo dado chega em duas colunas no fim do CSV: nivel_parcial
(hoje só existe o valor posicao — a camada de capítulo foi medida e arquivada por não
bater a barra de precisão pré-registrada, ver o model card) e codigo_parcial (os 4
dígitos). Scripts que já leem só a coluna abstem continuam funcionando sem qualquer
mudança — as colunas novas vêm depois, no fim da linha.
O nível posição foi medido em dados reais com 93,8% de acerto (321 itens, barra pré-registrada era 89,9%) e fecha em 5,6% das consultas que, de outra forma, seriam abstenções mudas — na prática, reduz de 25,6% para 19,9% a fatia de consultas reais que ficam sem nenhum código de apoio.
Servidor API
Iniciar o servidor
ncm servir --host 0.0.0.0 --porta 8000
Variáveis de ambiente obrigatórias/opcionais
| Variável | Tipo | Padrão | Descrição |
|---|---|---|---|
NCM_API_KEYS |
string | (obrigatória) | Chave(s) de API separadas por , ex: chave1,chave2. Sem isto o servidor não sobe (fail-closed) |
NCM_RATE_LIMIT_RPM |
int | 120 | Limite de requisições por minuto por chave (token bucket em memória; zera no restart) |
NCM_PACOTE |
path | ~/.ncm/pacote |
Caminho até o diretório do pacote-modelo (no Docker, tipicamente /pacote, ver seção Docker) |
Endpoints
POST /v1/classificar
Classifica um item único.
Request:
curl -X POST http://localhost:8000/v1/classificar \
-H "X-API-Key: sua-chave-api" \
-H "Content-Type: application/json" \
-d '{"descricao": "PARAFUSO SEXT ZINC M8X40 DIN933"}'
Response (200) — mesmo shape do --json da CLI (carga real, capturada rodando o
servidor local; resposta_parcial sempre presente, null quando não há posição
confiante o bastante):
{
"descricao": "PARAFUSO SEXT ZINC M8X40 DIN933",
"top3": [
{"ncm8": "73181500", "confianca": 0.996774},
{"ncm8": "73181100", "confianca": 0.000645},
{"ncm8": "86079900", "confianca": 0.000174}
],
"abstem": false,
"resposta_parcial": null,
"disclaimer": "Ferramenta de apoio · caráter orientativo · a responsabilidade pela classificação é do contribuinte/contador.",
"versao_pacote": "f-1"
}
descricao aceita 1 a 2000 caracteres. Corpo fora desse contrato (campo errado, string
vazia/longa demais) devolve 422 no formato problem+json (RFC 9457).
POST /v1/classificar-lote
Classifica múltiplos itens (máx. 200 por requisição). O corpo é {"descricoes": [...]}
— uma lista de strings, não uma lista de objetos {"descricao": ...}.
Request:
curl -X POST http://localhost:8000/v1/classificar-lote \
-H "X-API-Key: sua-chave-api" \
-H "Content-Type: application/json" \
-d '{
"descricoes": [
"PARAFUSO SEXT ZINC M8X40 DIN933",
"ARROZ(CLASSIFICAÇÂO SEM CARACTERÍSTICAS)"
]
}'
Response (200) — envelope com disclaimer/versao_pacote no topo, e cada resultado
com o mesmo shape do endpoint acima (também carregando disclaimer/versao_pacote).
O segundo item mostra resposta_parcial preenchido — o mesmo dado que a CLI devolve
para essa descrição, só que dentro do envelope de lote:
{
"resultados": [
{
"descricao": "PARAFUSO SEXT ZINC M8X40 DIN933",
"top3": [
{"ncm8": "73181500", "confianca": 0.996774},
{"ncm8": "73181100", "confianca": 0.000645},
{"ncm8": "86079900", "confianca": 0.000174}
],
"abstem": false,
"resposta_parcial": null,
"disclaimer": "Ferramenta de apoio · caráter orientativo · a responsabilidade pela classificação é do contribuinte/contador.",
"versao_pacote": "f-1"
},
{
"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"
}
],
"disclaimer": "Ferramenta de apoio · caráter orientativo · a responsabilidade pela classificação é do contribuinte/contador.",
"versao_pacote": "f-1"
}
O custo no rate limit é len(descricoes) tokens do balde — um lote de 50 itens custa 50,
não 1.
GET /v1/info
Retorna proveniência e configuração de calibração do pacote carregado. Não expõe
métricas de acurácia/ECE (não fazem parte do pacote de produto — ver seção ncm info
acima).
Request:
curl -H "X-API-Key: sua-chave-api" http://localhost:8000/v1/info
Response (200) — capturada rodando o servidor local com o pacote f-1. O campo
piso_posicao só aparece (não-nulo) em pacotes que trazem a calibração da resposta
parcial; pacotes antigos devolvem piso_posicao: null aqui:
{
"versao_pacote": "f-1",
"modelo_base": "neuralmind/bert-large-portuguese-cased",
"corrida": "f",
"data_export": "2026-07-18",
"temperatura": 0.7876692056028095,
"piso_confianca": 0.55,
"piso_margem": 0.1,
"piso_posicao": 0.7793302624741045,
"classes": 9748,
"disclaimer": "Ferramenta de apoio · caráter orientativo · a responsabilidade pela classificação é do contribuinte/contador."
}
GET /healthz
Verifica saúde do servidor (usado pelo Docker HEALTHCHECK). Não exige X-API-Key.
Request:
curl http://localhost:8000/healthz
Response (200):
{"status": "ok"}
Docker
Build
Deve ser executado na raiz do repositório (o Dockerfile copia o wheel de dist/):
# 1. Construir o wheel
uv build packages/ncm-classificador
# 2. Construir a imagem Docker
docker build -f packages/ncm-classificador/Dockerfile -t ncm-classificador .
Run
O pacote-modelo (1,3 GB) não é embarcado na imagem — entra como volume:
docker run \
-p 8000:8000 \
-v /caminho/local/do/pacote:/pacote:ro \
-e NCM_API_KEYS=sua-chave-api \
-e NCM_RATE_LIMIT_RPM=120 \
ncm-classificador
Notas:
/caminho/local/do/pacoteé o diretório local com o pacote-modelo baixado do Hugging Face (ver seção "Obtendo o pacote-modelo" abaixo)-v ... :romonta em modo somente-leitura (segurança)- Porta exposta: 8000 (ajuste com
-p HOST:8000conforme necessário) - O servidor está pronto quando logs mostram
Application startup complete
Obtendo o pacote-modelo
O modelo treinado é distribuído separadamente, no Hugging Face:
https://huggingface.co/DominuZ/ncm-classificador-bertimbau (CC BY 4.0).
O pacote de inferência são estes 5 itens do repositório: modelo.onnx (~1,3 GB),
tokenizer/, labels.json, inferencia.json e manifest.json — a integridade
(SHA-256 do manifesto) é conferida automaticamente antes de qualquer resposta.
pip install huggingface_hub
hf download DominuZ/ncm-classificador-bertimbau \
modelo.onnx labels.json inferencia.json manifest.json tokenizer/tokenizer.json tokenizer/tokenizer_config.json \
--local-dir ~/.ncm/pacote
Uma vez baixado, aponte a variável de ambiente NCM_PACOTE para o diretório
(ou use o padrão ~/.ncm/pacote, que dispensa a variável):
export NCM_PACOTE=~/.ncm/pacote
ncm classificar "PARAFUSO SEXT ZINC M8X40 DIN933"
ncm servir
Ou via Docker:
docker run -v /seu/caminho/pacote:/pacote -e NCM_PACOTE=/pacote ...
Limitações e abstenção
O modelo abstém em casos ambíguos ou com baixa confiança na predição (atualmente 48,8% dos itens na validação agregada). A abstenção é um recurso, não um defeito: itens abstidos devem ser revisados manualmente pela equipe de conformidade ou contador. Em dados reais, boa parte dessas abstenções ganha um apoio extra — a resposta parcial de posição (seção acima) — o que reduz de 25,6% para 19,9% a fatia de consultas reais que ficam sem nenhum código de apoio.
Exemplos reais de abstenção (saída do ncm lote, ver seção de uso acima): o item
FEIJÃO(CLASSIFICAÇÂO SEM CARACTERÍSTICAS) recebeu abstem=true porque o 1º candidato
(07082000) ficou em 36,9% de confiança, abaixo do piso de 55% configurado no pacote
f-1 — e a posição também não fechou, então é uma abstenção sem apoio nenhum
(resposta_parcial: null). Já o item ARROZ(CLASSIFICAÇÂO SEM CARACTERÍSTICAS) também
abstém na folha, mas fecha a posição (1006, 90,2% de confiança) — abstenção com apoio
parcial. Na CLI interativa (ncm classificar), a primeira aparece como a linha
ABSTEVE — confiança insuficiente; escale a um contador logo abaixo da tabela; a
segunda aparece como RESPOSTA PARCIAL, com a posição sugerida.
Links e suporte
- Modelo (pesos + pacote ONNX): https://huggingface.co/DominuZ/ncm-classificador-bertimbau — o model card documenta métricas, calibração, dados de treino e limitações
- Benchmark público (RFB-bench): https://huggingface.co/datasets/DominuZ/rfb-bench
- Dúvidas e problemas: use as discussões (aba Community) do repositório do modelo no Hugging Face
Licença: código MIT · pesos do modelo CC BY 4.0 Versão: 0.2.0 Última atualização: 2026-07-18
Metadata
Release files for ncm-classificador 0.2.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| ncm_classificador-0.2.0.tar.gz | 20.3 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| ncm_classificador-0.2.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 43.4 kB
Release files / ncm_classificador-0.2.0.tar.gz
| Download URL | ncm_classificador-0.2.0.tar.gz |
|---|---|
| Size | 20.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
559c0eb5b62fee1faacae35853620d5f24f49120fabeb926e2f603ee2b7464ae
|
|
BLAKE2b-256 checksum How to use checksums |
425320120d65a6c3a2e908bc7f518645fcc506ce88a75af4a606a6a31984eaf4
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.11.23 {"installer":{"name":"uv","version":"0.11.23","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":null,"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|
Release files / ncm_classificador-0.2.0-py3-none-any.whl
| Download URL | ncm_classificador-0.2.0-py3-none-any.whl |
|---|---|
| Size | 23.0 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
261456f0f3f50c311f9f92562be8e3bef3cd0b6317db3e3cefc3490b0c687965
|
|
BLAKE2b-256 checksum How to use checksums |
37d8d9b354e3d02b517294c36d5c59c3f589acdc7472e04c36cfd327101105b9
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.11.23 {"installer":{"name":"uv","version":"0.11.23","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":null,"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|