Why I trained it
A SOC queue does not need another essay about an alert. It needs a first note that a person can read and a machine can check. The note has to stay inside a closed label set, cite techniques only when an observation supports them, and recommend actions with a priority. A general chat model can discuss the alert. It does not keep that contract.
I fine-tuned a 1.7B model to test one question: can a small local model learn that draft from fictional incidents, well enough that its output is usable next to the original bundle?
What I compared
I gave both models the same system prompt, the same bundle, and the same decoding: greedy, thinking off, max_new_tokens 1024. The base is Qwen/Qwen3-1.7B at revision 70d244cc86ccca08cf5af4e1e306ecf908b1ad5e, with no adapter. The tuned arm is this adapter.
A row counts as a success (task_success_v1) only when all of these hold:
- The JSON parses and matches the schema, including legal disposition/classification pairs.
- Disposition and classification match the reference.
- Severity is exact or within one step on informational < low < medium < high < critical.
- ATT&CK technique F1 is at least 0.5, and an empty reference list stays empty.
- Reasoning and indicators introduce no value that is absent from the bundle.
- Recall on reference actions marked
now is at least 0.5.
The 236-row validation slice is the number in the metadata. The comparison with the untouched base model is a separate 146-row set: 68 terse log-line incidents, 68 incidents rewritten in a different prose style, and 10 hand-written demos. I wrote those holdouts in the same way as the training rows. They are not a customer SOC, and they are not the frozen test.
Results
Untouched Qwen3-1.7B vs this adapter
On the 146-row comparison set the base model produced JSON every time and a schema-valid record never.
Table with columns: Slice, Rows, Base task success, Base schema-valid, This adapter, Adapter schema-valid| Slice | Rows | Base task success | Base schema-valid | This adapter | Adapter schema-valid |
|---|
| Fresh log lines | 68 | 0 / 68 | 0 / 68 | 46 / 68 (0.676) | 66 / 68 |
| Shifted prose | 68 | 0 / 68 | 0 / 68 | 59 / 68 (0.868) | 68 / 68 |
| Hand-written demos | 10 |
The paired difference on the pooled 146 rows is +0.747 task success. The base model is not a weaker version of the same analyst. Its JSON cannot be ingested.
Across those 146 base outputs:
schema_version is missing on 146 / 146 rows.
mitre_attack is an empty object {} on 146 / 146 rows. No technique is ever mapped.
recommended_actions is a flat list of strings on 142 / 146 rows, not {code, priority} objects.
indicators is not a list on 112 / 146 rows, and the types it invents (command, ip, hash, path, file) are outside the closed set.
- Disposition includes values that are not in the schema (
susicious, ).
Both models write an Overview: paragraph (146 / 146). The base overview restates the alert and adds a generic next step. The adapter overview is shorter and is meant to agree with the JSON fields under it.
Examples
Two fictional incidents from the 10-demo set. Same prompt, greedy decoding, outputs copied as generated. The reference label is what I scored against. Neither model saw that label.
1. Helpdesk reset, then a new external sign-in
The password for natalia.sikora was reset by phone at the service desk with no ticket and no callback. Three minutes later the same account signed in from 198.51.100.73, an external address never seen before.
Reference: malicious / account_compromise / high / T1078 / recommend_disable_account.
Base model, no fine-tune. The JSON is not schema-valid. The class is credential_attack, MITRE is empty, the indicator type ip is illegal, and the action list never disables the account.
Overview: A user named natalia.sikora had their password reset by phone at the service desk with no ticket or callback verification. Three minutes later, they signed in from an external IP address, 198.51.100.73, which was never seen before. This incident suggests potential malicious activity, including credential theft and subsequent external access. The immediate next step is to investigate the external IP and verify if it was used for unauthorized access.{ "summary": "A user named natalia.sikora had their password reset by phone at the service desk with no ticket or callback verification. Three minutes later, they signed in from an external IP address, 198.51.100.73, which was never seen before. This incident suggests potential malicious activity, including credential theft and subsequent external access. The immediate next step is to investigate the external IP and verify if it was used for unauthorized access.", "disposition": "malicious", "classification": "credential_attack", "severity": "high", "reasoning": [ {"observation_id": "obs_1", "statement": "User natalia.sikora had their password reset by phone at the service desk with no ticket or callback verification."}, {"observation_id": "obs_1", "statement": "User natalia.sikora signed in from an external IP address, 198.51.100.73, which was never seen before."} ], "mitre_attack": {}, "indicators": [ {"type": "ip", "value": "198.51.100.73", "observation_id": "obs_1"}, {"type": "user", "value": "natalia.sikora", "observation_id": "obs_1"} ], "recommended_actions": [ "inspect_process_tree", "collect_file_hash", "review_network_connections", "review_dns_requests", "scope_account", "scope_host", "correlate_email_delivery", "verify_maintenance_window", "identify_asset_owner", "hunt_indicator" ], "confidence": 0.80}
This adapter. Schema-valid. The class is account takeover, the technique is tied to the observation, and the first action is to disable the account.
Overview: The password of natalia.sikora changed by phone at the service desk with no ticket and no callback, then natalia.sikora signed in from 198.51.100.73, an external address never used before. That order fits takeover.{ "schema_version": "soc-triage-v1", "summary": "The password of natalia.sikora changed by phone at the service desk with no ticket and no callback, then natalia.sikora signed in from 198.51.100.73, an external address never used before. That order fits takeover.", "disposition": "malicious", "classification": "account_compromise", "severity": "high", "reasoning": [ { "observation_id": "obs_1", "statement": "Password of natalia.sikora changed by phone at the service desk with no ticket and no callback, then natalia.sikora signed in from 198.51.100.73, an external address never used before." } ], "mitre_attack": [ { "technique_id": "T1078", "tactic_id": "TA0001", "supported_by": ["obs_1"] } ], "indicators": [ {"type": "user", "value": "natalia.sikora", "observation_ids": ["obs_1"]}, {"type": "ipv4", "value": "198.51.100.73", "observation_ids": ["obs_1"]} ], "recommended_actions": [ {"code": "recommend_disable_account", "priority": "now"}, {"code": "scope_account", "priority": "now"} ], "confidence": 0.8}
2. Caret-obfuscated command line
On WS-6412, bartosz.wrona ran cmd.exe /c p^o^w^e^r^s^h^e^l^l -nop -w hidden. No admin deployment or change ticket covers that host.
Reference: suspicious / malicious_execution / medium / T1059.003.
Base model, no fine-tune. Again not schema-valid. It calls a command-line execution a credential attack, leaves MITRE empty, and stores the command as an indicator of type command.
Overview: The incident involves a workstation (WS-6412) running Windows, where an employee (bartosz.wrona) executed a command-line process that included obfuscated command-line arguments. The alert describes the execution of a command that appears to be a PowerShell command, but the obfuscation makes it difficult to determine the exact intent. The severity is medium due to the potential for malicious activity, but the evidence is limited. The immediate next step is to investigate the command's origin and verify if it was executed under legitimate circumstances.{ "summary": "On WS-6412, bartosz.wrona executed a command-line process with obfuscated arguments. The command appears to be a PowerShell command, but the obfuscation makes it difficult to determine the intent. The severity is medium due to the potential for malicious activity, but the evidence is limited. The immediate next step is to investigate the command's origin and verify if it was executed under legitimate circumstances.", "disposition": "malicious", "classification": "credential_attack", "severity": "medium", "reasoning": [ {"observation_id": "obs_1", "statement": "cmd.exe was executed by bartosz.wrona on WS-6412."} ], "mitre_attack": {}, "indicators": { "type": "command", "value": "p^o^w^e^r^s^h^e^l^l", "observation_id": "obs_1" }, "recommended_actions": [ "inspect_process_tree", "collect_file_hash", "review_network_connections", "review_dns_requests", "escalate_to_incident_response" ], "confidence": 0.80}
This adapter. Schema-valid, and this row passes the full metric. Disposition stays suspicious because the obfuscation is one fact, not a confirmed payload. The technique is T1059.003.
Overview: On WS-6412, bartosz.wrona ran cmd.exe /c p^o^w^e^r^s^h^e^l^l -nop -w hidden. The split-by-caret artifact alone cannot carry a verdict.{ "schema_version": "soc-triage-v1", "summary": "On WS-6412, bartosz.wrona ran cmd.exe /c p^o^w^e^r^s^h^e^l^l -nop -w hidden. The split-by-caret artifact alone cannot carry a verdict.", "disposition": "suspicious", "classification": "malicious_execution", "severity": "medium", "reasoning": [ { "observation_id": "obs_1", "statement": "cmd.exe /c p^o^w^e^r^s^h^e^l^l -nop -w hidden on WS-6412, user bartosz.wrona." } ], "mitre_attack": [ { "technique_id": "T1059.003", "tactic_id": "TA0002", "supported_by": ["obs_1"] } ], "indicators": [ {"type": "hostname", "value": "WS-6412", "observation_ids": ["obs_1"]}, {"type": "user", "value": "bartosz.wrona", "observation_ids": ["obs_1"]} ], "recommended_actions": [ {"code": "inspect_process_tree", "priority": "now"}, {"code": "collect_file_hash", "priority": "next"} ], "confidence": 0.55}
The format change is stable. The judgement is not. On a scheduled logrotate run the base model returned malicious / credential_attack / high and recommended incident response. This adapter returned a valid record, but the record was still wrong: suspicious / defense_evasion. I kept that miss in the results above rather than only showing the rows the adapter gets right.
Validation, this adapter only
The base model was not scored on the 236-row validation slice in this comparison. On that slice the adapter does:
Table with columns: Check, Score| Check | Score |
|---|
Overview: present | 236 / 236 |
| JSON parses | 236 / 236 |
| Schema valid | 234 / 236 (0.992) |
task_success_v1 | 218 / 236 (0.924) |
| Disposition | 0.953 |
| Classification | 0.970 |
| Severity within one | 0.992 |
| Technique check | 0.953 |
Benign precision is the share of predicted benign rows that are benign in the reference. A false benign is the expensive mistake, because it invites someone to close a real alert. On validation and on the shifted-prose slice this adapter made none. On the fresh log-line slice it made 4.
What I concluded
The fine-tune does the job I trained it for. The small model learns the contract: a readable overview, a closed decision, techniques tied to observation ids, and actions a queue can rank. The base model of the same size does not. It writes fluent text and illegal JSON, and it files very different attacks under one or two labels.
It does not replace an analyst. On the 10 hand-written demos only 4 rows pass the full metric. The misses are analyst misses, not format misses: shadow-copy deletion called defense evasion instead of impact, a scheduled log rotation called suspicious, a single failed SSH login called a credential attack, a mailbox-forward rule filed under a broader exfiltration technique than the reference. Confidence is not a calibrated probability. Do not show it to an analyst as one.
Use the overview as the note a person reads, and the JSON as the object a schema check accepts. Keep a person, a schema check, and a benign-precision check in front of any close or containment action.
Intended use
Given one soc-triage-v1 event bundle, produce one overview and one analysis JSON for a human queue.
Out of scope
- Autonomous containment, blocking, or account disablement.
- Replacing a SOC analyst or an incident commander.
- Treating vendor severity or vendor ATT&CK tags as ground truth.
- Bundles outside this schema, other languages, or other ticketing products, until those are evaluated.
- Any claim about the frozen test split. That split has not been run.
How to run
Load the base model at the pinned revision, attach this adapter, and generate with thinking off and sampling off. The system text is prompts/soc_triage_v1.txt in this repository (sha256 607f60db47a7c9f395124619e8151099993a78c18bc5bea8fa99686188864daa). The user text is Triage the following event bundle.\n plus the bundle JSON (indent=2).
import jsonimport torchfrom peft import PeftModelfrom transformers import AutoModelForCausalLM, AutoTokenizer base = "Qwen/Qwen3-1.7B"revision = "70d244cc86ccca08cf5af4e1e306ecf908b1ad5e"adapter = "ip4pryk/qwen3-1.7b-soc-triage-v1" tokenizer = AutoTokenizer.from_pretrained(base, revision=revision)model = AutoModelForCausalLM.from_pretrained( base, revision=revision, dtype=torch.bfloat16, device_map="auto")model = PeftModel.from_pretrained(model, adapter)model.eval() messages = [ {"role": "system", "content": open("prompts/soc_triage_v1.txt").read()}, { "role": "user", "content": "Triage the following event bundle.\n" + json.dumps(bundle, ensure_ascii=False, indent=2), },]prompt = tokenizer.apply_chat_template( messages, tokenize=False, add_generation_prompt=True, enable_thinking=False,)inputs = tokenizer(prompt, return_tensors="pt").to(model.device)with torch.no_grad(): output = model.generate(**inputs, do_sample=False, max_new_tokens=1024)text = tokenizer.decode( output[0][inputs["input_ids"].shape[-1] :], skip_special_tokens=True)
Split text on the first blank line. The first part is the overview. Parse the second part as one JSON object. Reject markdown fences, a second object, or any field value outside the closed lists.
Output contract
disposition: malicious, suspicious, benign, inconclusive.
classification: credential_attack, phishing, malicious_execution, privilege_escalation, lateral_movement, command_and_control, data_exfiltration, persistence, defense_evasion, reconnaissance, account_compromise, impact, policy_violation, benign_activity, unknown.
benign pairs only with benign_activity. inconclusive pairs only with unknown.
severity: informational, low, medium, high, critical.
Action priorities: now, next, later. Action codes include scope_account, scope_host, recommend_disable_account, recommend_isolate_host, close_false_positive, and monitor_only. The full list is in the system prompt. Recommendations are advice.
Training
I trained on fictional English bundles only. Each row is one bundle and one reference analysis. The training mix is the original rows plus paraphrases that keep every entity, number, and negation, and that add no decision word. Loss is on the assistant text only. The prompt and the bundle are masked.
Unsloth QLoRA, 4-bit base weights, LoRA rank 16, alpha 32, dropout 0, on all attention and MLP projections. AdamW 8-bit, cosine schedule, learning rate 2e-4, warmup 10 steps, one epoch, seed 7, bfloat16, effective batch 32, sequence length 4096. About 17.4 million trainable parameters. I trained on one NVIDIA L4 (22 GB).
The saved adapter is plain PEFT. Unsloth is not required to load it.
Limitations
The training text is fictional. It does not measure a real customer environment, a real ticketing vocabulary, or a real base rate of benign alerts. Results move by scenario family from one training seed to the next. Validation and the unused frozen test share some near-duplicate families, so validation can look stronger than a truly new incident. Treat every indicator as untrusted until it appears in the bundle.
Citation
No paper yet. Cite this repository and name the checkpoint: run 035, seed 7 (ip4pryk/qwen3-1.7b-soc-triage-v1).
Open an issue on the project repository. Do not send live incident data.