LoRA fine-tune a small LLM on maintenance logs
Turn a synthetic maintenance log into one JSON object with TRL, PEFT, and a small instruct model. The training set is generated, the results table starts empty, and a two-step CPU smoke test only checks that the loss is finite.
A maintenance log is a paragraph. The thing a database wants is one JSON object: which asset, which site, how bad it was, what someone did, and whether the job is still open. The LoRA fine-tune repo is a small supervised pipeline for that task. It uses Hugging Face transformers, peft, datasets, and trl.
The training set is synthetic. A seeded generator writes every row, and every row says so. The repo does not check in trained weights, and it does not check in measured scores. The results table is a placeholder with empty cells. If you want a number, you run eval yourself and read results/latest_eval.json. That file is not a claim I am copying into this post.
Python 3.11 or newer. The install below uses a CPU wheel for PyTorch so a laptop without a CUDA toolkit can still run the smoke test.
python -m venv .venv
source .venv/bin/activate
pip install --upgrade pip
pip install torch --index-url https://download.pytorch.org/whl/cpu
pip install -e ".[dev]"The record the model has to emit
Each target object has ten fields: asset_id, site, reported_at, severity, component, symptom, action_taken, downtime_minutes, status, and technician.
severity is low, medium, high, or critical. status is open, in_progress, or resolved. downtime_minutes is a non-negative integer. reported_at is a UTC timestamp like 2024-03-12T08:15:00Z.
A synthetic log carries those values in labeled text. The assistant target is the canonical JSON object, not a paraphrase. The system prompt in maintlog.prompts tells the model to use only those keys, names the allowed severity and status values, says downtime is an integer, and says to reply with JSON only, with no markdown.
The user message is the log. The assistant message, used as the training completion, is canonical_json(record). Extra keys and missing keys fail validation in maintlog.schema before they ever become a training target.
Generate the set, then look at it
configs/data.yaml writes data/synthetic/maintenance_logs.jsonl. It asks for 48 train rows and 16 eval rows, with seed 42. The checked-in file under data/synthetic/ is that kind of labeled synthetic log. Regenerate it when you want a fresh draw from the same generator:
python scripts/generate_data.py --config configs/data.yamlOpen a few lines before you train. You should see the labeled source text and a JSON object with the ten fields. If a row does not say it is synthetic, the generator is not the one in this repo.
The LoRA run the config actually describes
configs/train.yaml is the intended fine-tune. CI does not run it. The base model is Qwen/Qwen2.5-0.5B-Instruct. HuggingFaceTB/SmolLM2-360M-Instruct is a drop-in model_name_or_path if you would rather start there. The dataset path is the JSONL above. Adapters land in outputs/lora-qwen2.5-0.5b.
The hyperparameters in that file, which are settings rather than measured results:
max_stepsis 100, withnum_train_epochs1. Steps win once they are set.per_device_train_batch_sizeis 2 andgradient_accumulation_stepsis 4.learning_rateis 0.0002.max_lengthis 512.- LoRA
ris 16,alphais 32, dropout is 0.05. - Target modules are
q_proj,k_proj,v_proj, ando_proj. - Seed is 42. Logging is every 5 steps. Checkpoints save every 50 steps.
eval_strategyisno. use_cpuis false. The Qwen end-of-string token in the file is the model's im-end token.- The split key is
train.
scripts/train.py builds a LoraConfig and an SFTTrainer. SFTConfig is constructed by inspecting the signature of the installed TRL version and dropping keys that version does not accept, with a log line that names the dropped keys. That keeps the same YAML usable when TRL renames an argument. It does not hide a failed import.
python scripts/train.py --config configs/train.yamlTraining Qwen2.5-0.5B-Instruct downloads that model from the Hub. On CPU it is slow. That is a property of the machine, not a number I am going to invent. If you only want to prove the loop runs, use the smoke config instead of waiting on the 0.5B model.
The smoke test is not a quality run
configs/smoke.yaml points at hf-internal-testing/tiny-random-LlamaForCausalLM. It takes 2 steps, batch size 1, learning rate 0.0001, LoRA r 4 and alpha 8, dropout 0, and only q_proj and v_proj. use_cpu is true. chat_template is plain, because that tiny tokenizer does not ship a usable chat template.
The plain template is a Jinja string in maintlog.prompts. It prints role: content for each message and, when a generation prompt is requested, ends with assistant:. The completion-only loss expects the generation prompt to be a prefix of the full conversation. The smoke config exists so that prefix still lines up.
One pytest runs two LoRA steps on CPU with that tiny model. It checks that training finishes and that the loss is finite. It is a smoke test of the loop. It is not a quality claim, and the README says the same thing. GitHub Actions runs pytest -q. The workflow does not call the merge script.
pytest -qThe rest of the suite covers the generator, the JSONL loader, the metric math, config parsing, the demo preview, and the missing-token guard on the Hub script.
How eval scores a prediction
configs/eval.yaml loads the same Qwen base, the adapter directory from the train config, and the eval split of the JSONL. It writes results/latest_eval.json. It does not fill the placeholder table in the README or in results/eval_results.json. max_new_tokens is 256. max_samples is null, so the split is not truncated by the config. use_cpu is true in that file. Change it if you are scoring on a GPU.
python scripts/evaluate.py --config configs/eval.yamlMetrics live in maintlog.metrics and are computed only when you run eval:
- JSON validity is the fraction of predictions that contain a JSON object.
- Field accuracy is the mean, over examples, of how many required fields match. An unparsable prediction scores 0 on every field.
severityandstatusare compared case-insensitively. Other strings are stripped and compared exactly.downtime_minutesalso accepts a whole-number float or a digit string.
The README table is deliberately empty:
| Split | JSON validity | Field accuracy |
|---|---|---|
| eval | empty | empty |
Those dashes are empty cells, not scores. I am not filling them in from a run I did not publish in the repo.
A demo you can open before the adapter exists
scripts/demo.py starts a Gradio app on http://127.0.0.1:18765. Before an adapter is loaded, the page shows a rule-based preview of the labeled synthetic format. That preview is a regex parser. The UI labels it as a preview, so nobody mistakes it for the fine-tuned model. When the adapter directory exists, the same app can generate with it.
python scripts/demo.pyPaste one of the synthetic logs first. You should see the ten fields come back from the regex path, and you should see that the page tells you which path produced them.
Merge and push, only with a token
scripts/merge_push.py reads HF_TOKEN and is left out of CI. configs/merge.yaml merges the Qwen base with outputs/lora-qwen2.5-0.5b into outputs/merged-maintlog. The repo_id in the file is your-hf-username/maintlog-json-lora. Change that before you push. private is true, so the upload is private by default.
HF_TOKEN=hf_... python scripts/merge_push.py --config configs/merge.yaml
python scripts/merge_push.py --config configs/merge.yaml --merge-only--merge-only writes the merged model locally and does not read HF_TOKEN. The missing-token guard is tested. A push without a token should fail before it talks to the Hub.
The order that matches the README is: generate, train, evaluate, demo. Merge is optional and comes last. Keep the placeholder results file as the empty record of "no quality run is stored here", and treat latest_eval.json as the only score file a command in this repo will write.