Qwen3-1.7B — Gerador de Questões de Matemática (SAEB, offline/mobile)
Fine-tuning do
Qwen3-1.7B para gerar
questões de matemática de múltipla escolha no padrão
SAEB (avaliação
nacional da educação básica brasileira), em
JSON estruturado, otimizado
para rodar
offline em dispositivos mobile via llama.cpp/GGUF.
- Repositório de código e dataset: https://github.com/eltonsarmanho/TreinamentoModeloQuestoes
- Modelo base:
unsloth/Qwen3-1.7B (4-bit NF4)
- Método: QLoRA (SFT via TRL
SFTTrainer, framework Unsloth)
- Formato de deploy: GGUF quantizado (Q4_K_M) para llama.cpp
Arquivos neste repositório
| Caminho | Conteúdo |
|---|
lora/ | Adaptadores LoRA (não mesclados) — para re-mesclar ou re-quantizar com outro método |
gguf/ | Modelo mesclado e quantizado em Q4_K_M (~1.1GB), pronto para llama.cpp |
data/ | train.jsonl (273 reais + 244 exemplos sintéticos, 396 questões) e val.jsonl (30 reais) usados no fine-tuning |
eval_report.json | Métricas de qualidade estrutural, perplexity e velocidade (ver abaixo) |
Por que este modelo
A equipe já havia escolhido o Qwen3-1.7B como base para geração offline de
questões no app mobile, mas o modelo base é lento (gera blocos de
"thinking" longos) e frequentemente falha em produzir um formato utilizável.
Este fine-tuning ataca as duas causas:
- Formato/qualidade — SFT com QLoRA sobre ~300 questões reais do banco
SAEB (mais dados sintéticos de aritmética, com resposta garantida por
computação), ensinando o modelo a produzir consistentemente o contrato
JSON exigido pelo app mobile:
{"questoes": [...]} com enunciado, 5
alternativas (A–E), resolução passo a passo, resposta correta (letra) e
dificuldade (EASY/MEDIUM/HARD).
- Velocidade — treino e inferência em modo non-thinking
(
enable_thinking=False), eliminando a cadeia de pensamento solta, com
saída curta (~150–300 tokens) e exportação para GGUF Q4_K_M, o
formato de fato para LLMs offline em Android/iOS.
Dados de treinamento
Extraídos de um banco SQLite com 553 itens de avaliação (SAEB), filtrados
para:
- disciplina = Matemática (exclui 19 registros de outras áreas/nulos)
- questões 100% textuais — excluídas 230 questões com imagem no enunciado
ou nas alternativas, para não ensinar o modelo a referenciar figuras
inexistentes
- sem alternativas duplicadas (exclui 1 item em que duas alternativas tinham
o mesmo texto, tornando o gabarito ambíguo)
Resultado: 303 questões reais válidas, split 90/10 (273 treino / 30
validação) estratificado por (ano escolar, dificuldade). Cobertura: anos
2º/5º/9º, dificuldades Fácil/Moderado/Difícil, 27 habilidades (descritores)
distintas. data/val.jsonl contém só questões reais — a validação sempre
mede o modelo no que ele de fato vai enfrentar em produção.
Aumentação sintética (generate_synthetic.py): as ~300 questões reais
ensinam formato/estilo, mas são poucas para o modelo generalizar aritmética.
O treino atual soma 244 exemplos sintéticos (396 questões — parte dos
exemplos agrupa 2–5 questões num único "questoes": [...], ensinando o
modelo a responder pedidos de lote) de adição, subtração, multiplicação,
divisão, porcentagem, potenciação e frações (H07–H09) — com a resposta
calculada em Python antes de montar a questão (nunca por um LLM, portanto
nunca pode estar errada) — usando as mesmas tuplas (ano, habilidade,
descrição, dificuldade) reais do banco. Dataset de treino final: 517
exemplos de treino (273 reais + 244 sintéticos).
Formato do exemplo (chat SFT) — contrato fixo definido pela equipe de
integração do app mobile, sem exceção:
system: instrução fixa definindo o papel e o schema JSON de saída
user: "Gere {quantidade} questão(ões) de matemática. Ano: {ano}. Habilidade: {habilidade} — {descrição}. Dificuldade: {dificuldade}."
(o app pode pedir mais de uma questão por chamada; o modelo sempre responde
com uma lista, mesmo quando quantidade=1)
assistant: JSON no formato {"questoes": [{"enunciado", "alternativas" (A–E), "resolucao_passo_a_passo", "resposta_correta", "difficulty"}, ...]}.
Internamente o modelo é treinado a emitir resolucao_passo_a_passo antes
de resposta_correta — como a geração é token a token, "mostrar o
trabalho" antes de se comprometer com a letra final ajuda o modelo a gerar
uma resposta consistente com a conta (o modelo roda em modo non-thinking,
sem <think>, então essa é a única âncora de raciocínio disponível). JSON
não garante ordem de chaves para quem consome por nome, então isso não
afeta o contrato, só a qualidade da geração.
Método de treinamento
QLoRA (Dettmers et al., 2023) via Unsloth + TRL SFTTrainer:
modelo base congelado em 4-bit NF4, apenas os adaptadores LoRA são treinados
— viável em GPUs com pouca VRAM (treinado numa RTX 3060 Laptop, 6GB).
| Hiperparâmetro | Valor |
|---|
| Modelo base | unsloth/Qwen3-1.7B (unsloth/qwen3-1.7b-unsloth-bnb-4bit) |
max_seq_length | 1024 |
| LoRA rank (r) / alpha | 16 / 32 |
| LoRA dropout | 0.0 |
| Target modules | q_proj, k_proj, v_proj, o_proj, gate_proj, up_proj, down_proj |
| Batch efetivo | 2 × 8 grad. accumulation = 16 |
| Learning rate | 2e-4, cosine, warmup 5% |
| Épocas | até 3, com early stopping (patience=2) por eval_loss |
| Otimizador | adamw_8bit, weight decay 0.01 |
| Precisão | bf16 |
| Loss | apenas nos tokens de resposta do assistant (train on completions only) |
| Seed | 42 |
Framework versions: TRL 0.24.0 · Transformers 5.5.0 · PyTorch 2.11.0.
Resultados de avaliação
Avaliado sobre as 30 questões de validação (reais, não vistas no treino),
geração real com temperature=0.7, top_p=0.8 (recomendado pela Qwen para
modo non-thinking), após o retreino com o contrato de schema atual (517
exemplos de treino: 273 reais + 244 sintéticos):
| Métrica estrutural | Resultado |
|---|
| JSON válido | 100% |
Wrapper {"questoes": [...]} válido | 100% |
| Schema completo (todas as chaves) | 100% |
resposta_correta válida (A–E) | 100% |
| 5 alternativas distintas | 90,0% |
difficulty válida (EASY/MEDIUM/HARD) | 100% |
| Menções indevidas a "figura/imagem" | 13,3% |
Consistência resposta_correta ↔ resolucao_passo_a_passo* | 72,7% (11/30 amostras verificáveis) |
| Métrica de linguagem | Resultado |
|---|
| Perplexity (resposta de referência) | 1,772 |
| Velocidade (GPU, dev) | Resultado |
|---|
| Latência média | 5,09 s |
| Latência p95 | 6,82 s |
| Tokens/s de geração | 30,3 |
| Tokens de saída (média) | 152,7 |
* Consistência resposta_correta↔resolucao_passo_a_passo: checagem
determinística (schema_utils.check_consistency, sem LLM) que extrai uma
expressão aritmética simples "a op b = r" do texto de
resolucao_passo_a_passo e confere se bate com o valor da alternativa
apontada por resposta_correta. Medida só sobre as N amostras (aqui, 11 de
30) em que essa expressão existe de forma reconhecível no texto — questões
com raciocínio verbal (sem "a op b = r" explícito) ou resposta textual
(fração/porcentagem) não entram na conta. Este teto é conhecido e aceito:
uma versão anterior deste pipeline tinha um campo resposta dedicado ao
valor da resposta, que elevava esta métrica a 100% por comparação exata; ele
foi removido para seguir o contrato de schema exigido pela integração com o
app mobile, e a cobertura de verificação voltou a depender de regex sobre texto livre. Mitigado, não
eliminado, pelo pipeline de best-of-N + correção determinística em test_model.py.
Velocidade — o número relevante para mobile é o do GGUF quantizado, não
o do modelo em 4-bit via bitsandbytes+HF generate() (caminho não
otimizado, usado só durante o desenvolvimento). Benchmark real do .gguf
com llama-bench (CPU, proxy de mobile — sem aceleração de GPU, máquina
ociosa):
| Config | Prompt processing | Geração |
|---|
| 4 threads | 123 tok/s | 29,7 tok/s |
| 16 threads | 116 tok/s | 17,1 tok/s (contenção de banda de memória) |
Geração é limitada por banda de memória, não por núcleos — por isso menos
threads foi mais rápido. O número real em um SoC de celular deve ser medido
no dispositivo alvo, mas ~30 tok/s de CPU aqui já indica viabilidade para uso
offline (≈153 tokens de saída médios → poucos segundos por questão).
Validação do artefato real (GGUF): test_model.py --batch foi rodado
antes desta migração de contrato — o relatório anterior media
gabarito_valido/justificativas_distintas (schema antigo) e não reflete o
.gguf atual. Rode python src/test_model.py --batch para gerar
outputs/eval_report_gguf.json com as métricas do contrato atual
(wrapper_valido, resposta_valida, difficulty_valida,
consistencia_resposta_correta) sobre o artefato exportado hoje.
Como usar (adaptadores LoRA, com Unsloth)
1from unsloth import FastLanguageModel
2
3model, tokenizer = FastLanguageModel.from_pretrained(
4 model_name="<este-repo>/lora",
5 max_seq_length=1024,
6 load_in_4bit=True,
7)
8FastLanguageModel.for_inference(model)
9
10messages = [
11 {"role": "system", "content": "Você é um gerador de questões de matemática no padrão SAEB..."},
12 {"role": "user", "content": "Gere 1 questão(ões) de matemática. Ano: 5º ano. Habilidade: H08 — ... . Dificuldade: Fácil."},
13]
14inputs = tokenizer.apply_chat_template(
15 messages, tokenize=True, add_generation_prompt=True,
16 enable_thinking=False, return_tensors="pt",
17).to(model.device)
18output = model.generate(inputs, max_new_tokens=512, temperature=0.7, top_p=0.8)
19print(tokenizer.decode(output[0, inputs.shape[1]:], skip_special_tokens=True))
Como usar (GGUF, llama.cpp — uso mobile/offline)
1llama-cli -m gguf/qwen3-1.7b.Q4_K_M.gguf --temp 0.7 --top-p 0.8 \
2 --grammar-file grammars/questao.gbnf \
3 -p "Gere 1 questão(ões) de matemática. Ano: 9º ano. Habilidade: H17 — ... . Dificuldade: Moderado."
Recomendado usar a grammar GBNF (grammars/questao.gbnf) na inferência
para forçar o contrato exato — wrapper {"questoes": [...]}, 5 alternativas
(A–E), resposta_correta em {A,B,C,D,E}, difficulty em
{EASY,MEDIUM,HARD} — cobrindo os casos em que o modelo ainda erra o
formato.
Limitações
resposta_correta ocasionalmente inconsistente com
resolucao_passo_a_passo: em alguns casos a conta na resolução está
certa, mas a letra não bate com ela — sintoma esperado de um modelo
pequeno (1.7B) sem chain-of-thought explícito, gerando de forma
autorregressiva. Mitigado (não eliminado) por: (1) treinar o modelo a
emitir a resolução antes de se comprometer com a letra, e (2)
generate_synthetic.py, que soma exemplos de aritmética (e frações) com
resposta garantida por computação. Ver métrica "Consistência
resposta_correta↔resolucao_passo_a_passo" acima — hoje limitada a ~73%
porque o contrato do app não permite um campo dedicado ao valor da
resposta (só a letra), então a checagem depende de regex sobre texto
livre. Para produção, recomenda-se rodar a checagem determinística de
schema_utils.check_consistency() (e o best-of-N de test_model.py)
como validação pós-geração antes de exibir a questão ao usuário.
- Dataset real pequeno (~300 exemplos): ensina bem formato e estilo
pedagógico, mas sozinho não é suficiente para ampliar conhecimento
matemático além do que já estava no modelo base — daí a augmentação
sintética de aritmética e frações.
- Não gera questões com figuras/gráficos (removidas do treino de propósito);
ainda assim ~13% das saídas de validação mencionam "figura/imagem" mesmo
sem terem sido pedidas — acompanhar essa métrica nas próximas iterações.
- A 5ª alternativa (E) das questões reais do banco é sempre um distrator fixo
("Nenhuma das alternativas anteriores"), já que o banco original só tem
A–D — só as questões sintéticas têm 5 distratores pedagogicamente
distintos.
- Leve viés na distribuição das respostas corretas (letras C/D
sub-representadas), herdado do próprio banco de dados original.
- Caminho de evolução recomendado: destilação de dados sintéticos mais
ampla — gerar questões contextualizadas (não só cálculo puro) com um
modelo maior, filtrar por qualidade/consistência e reincorporar ao
dataset de treino.
Citação / proveniência dos dados
Questões extraídas de um banco de itens SAEB de uso interno do projeto.
Código de treinamento e extração disponível no repositório GitHub linkado
acima.