SahilGoel commited on
Commit
163186d
·
verified ·
1 Parent(s): 5207433

Upload code/finetune_qwen.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. code/finetune_qwen.py +194 -0
code/finetune_qwen.py ADDED
@@ -0,0 +1,194 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Continue fine-tuning Qwen2.5-0.5B for category and company inference."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import sys
9
+ from pathlib import Path
10
+
11
+
12
+ PACKAGE_ROOT = Path(__file__).resolve().parent.parent
13
+ if str(PACKAGE_ROOT) not in sys.path:
14
+ sys.path.insert(0, str(PACKAGE_ROOT))
15
+
16
+ from pipeline.augment_training_data import sanitize_training_description
17
+ from pipeline.company_inference import infer_company_name
18
+ from pipeline.training_schema import CATEGORIES, INCOME_CATEGORIES, NON_INCOME_CATEGORIES
19
+
20
+ MODEL_NAME = "Qwen/Qwen2.5-0.5B"
21
+ DATA_PATH = PACKAGE_ROOT / "data" / "training_data.json"
22
+ OUTPUT_DIR = PACKAGE_ROOT / "data" / "qwen-lora-adapter-0.5b"
23
+
24
+ SYSTEM_PROMPT = (
25
+ "You are a bank transaction classifier for Indian bank statements. "
26
+ "Given a raw transaction description, infer both its category and the actual company when evidence exists. "
27
+ "Respond with ONLY a JSON object: "
28
+ '{"category": "<category>", "company_name": "<company_or_null>", "is_income": false, "confidence": 0.0}. '
29
+ f"Categories: {', '.join(CATEGORIES)}. "
30
+ "Use company_name=null for personal transfers or when the company is not supported by the description. "
31
+ "Credits to known employers = salary. UPI to person names = personal_transfer. "
32
+ "Refunds/reversals = original category. If truly unknown, category=unclassified, confidence=0.30."
33
+ )
34
+
35
+
36
+ def format_training_example(item: dict) -> dict[str, str]:
37
+ """Create one category + company prompt/completion training pair."""
38
+ description = item["description"]
39
+ category = item["category"]
40
+ if "is_income" in item:
41
+ is_income = bool(item["is_income"])
42
+ elif category in NON_INCOME_CATEGORIES:
43
+ is_income = False
44
+ else:
45
+ is_income = item.get("type") == "credit" or category in INCOME_CATEGORIES
46
+ company_name = infer_company_name(
47
+ description,
48
+ category=category,
49
+ explicit_name=item.get("company_name") or item.get("merchant") or item.get("counterparty"),
50
+ )
51
+ sanitized_description = sanitize_training_description(
52
+ description,
53
+ category=category,
54
+ company_name=company_name,
55
+ )
56
+ prompt = f"### System:\n{SYSTEM_PROMPT}\n\n### Input:\n{sanitized_description}\n\n### Output:\n"
57
+ completion = json.dumps({
58
+ "category": category,
59
+ "company_name": company_name,
60
+ "is_income": is_income,
61
+ "confidence": 0.90,
62
+ })
63
+ return {"prompt": prompt, "completion": completion}
64
+
65
+
66
+ def prepare_training_examples(data: list[dict]) -> list[dict[str, str]]:
67
+ """Deduplicate sanitized prompts and reject contradictory completions."""
68
+ grouped: dict[str, dict[str, dict[str, str]]] = {}
69
+ for item in data:
70
+ example = format_training_example(item)
71
+ grouped.setdefault(example["prompt"], {})[example["completion"]] = example
72
+ return [
73
+ next(iter(grouped[prompt].values()))
74
+ for prompt in sorted(grouped)
75
+ if len(grouped[prompt]) == 1
76
+ ]
77
+
78
+
79
+ def balance_training_examples(
80
+ examples: list[dict[str, str]],
81
+ *,
82
+ income_target: int = 20,
83
+ ) -> list[dict[str, str]]:
84
+ """Oversample represented income classes after conflict-safe deduplication."""
85
+ by_category: dict[str, list[dict[str, str]]] = {}
86
+ for example in examples:
87
+ category = json.loads(example["completion"])["category"]
88
+ by_category.setdefault(category, []).append(example)
89
+
90
+ balanced = list(examples)
91
+ for category in sorted(INCOME_CATEGORIES):
92
+ category_examples = by_category.get(category, [])
93
+ if not category_examples or len(category_examples) >= income_target:
94
+ continue
95
+ balanced.extend(
96
+ category_examples[index % len(category_examples)]
97
+ for index in range(income_target - len(category_examples))
98
+ )
99
+ return balanced
100
+
101
+
102
+ def load_training_data():
103
+ """Load, sanitize, deduplicate, balance, and format labeled transactions."""
104
+ from datasets import Dataset
105
+
106
+ with open(DATA_PATH, encoding="utf-8") as handle:
107
+ data = json.load(handle)
108
+ return Dataset.from_list(balance_training_examples(prepare_training_examples(data)))
109
+
110
+
111
+ def _load_trainable_model(*, fresh: bool):
112
+ import torch
113
+ from peft import LoraConfig, PeftModel, TaskType, get_peft_model
114
+ from transformers import AutoModelForCausalLM
115
+
116
+ base_model = AutoModelForCausalLM.from_pretrained(
117
+ MODEL_NAME,
118
+ torch_dtype=torch.float16,
119
+ device_map="mps",
120
+ trust_remote_code=True,
121
+ )
122
+ adapter_file = OUTPUT_DIR / "adapter_model.safetensors"
123
+ if adapter_file.exists() and not fresh:
124
+ print(f"Continuing from adapter: {OUTPUT_DIR}")
125
+ return PeftModel.from_pretrained(base_model, str(OUTPUT_DIR), is_trainable=True)
126
+
127
+ print("Starting a fresh LoRA adapter")
128
+ return get_peft_model(
129
+ base_model,
130
+ LoraConfig(
131
+ task_type=TaskType.CAUSAL_LM,
132
+ r=8,
133
+ lora_alpha=16,
134
+ lora_dropout=0.05,
135
+ bias="none",
136
+ target_modules=["q_proj", "k_proj", "v_proj", "o_proj"],
137
+ ),
138
+ )
139
+
140
+
141
+ def main(*, epochs: float = 2.0, fresh: bool = False) -> None:
142
+ from transformers import AutoTokenizer
143
+ from trl import SFTConfig, SFTTrainer
144
+
145
+ print(f"Loading model: {MODEL_NAME}")
146
+ tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME, trust_remote_code=True)
147
+ tokenizer.pad_token = tokenizer.eos_token
148
+ model = _load_trainable_model(fresh=fresh)
149
+ model.print_trainable_parameters()
150
+
151
+ print("Loading training data...")
152
+ dataset = load_training_data()
153
+ company_labels = sum(
154
+ json.loads(completion)["company_name"] is not None
155
+ for completion in dataset["completion"]
156
+ )
157
+ print(f"Training samples: {len(dataset)}; company labels: {company_labels}")
158
+
159
+ trainer = SFTTrainer(
160
+ model=model,
161
+ args=SFTConfig(
162
+ output_dir=str(OUTPUT_DIR),
163
+ num_train_epochs=epochs,
164
+ per_device_train_batch_size=2,
165
+ gradient_accumulation_steps=8,
166
+ learning_rate=1e-4 if not fresh else 2e-4,
167
+ warmup_ratio=0.05,
168
+ logging_steps=10,
169
+ save_strategy="epoch",
170
+ save_total_limit=2,
171
+ bf16=False,
172
+ fp16=False,
173
+ optim="adamw_torch",
174
+ report_to="none",
175
+ max_length=512,
176
+ ),
177
+ train_dataset=dataset,
178
+ processing_class=tokenizer,
179
+ )
180
+
181
+ print("Starting continued training..." if not fresh else "Starting training...")
182
+ trainer.train()
183
+ print(f"Saving LoRA adapter to {OUTPUT_DIR}")
184
+ model.save_pretrained(str(OUTPUT_DIR))
185
+ tokenizer.save_pretrained(str(OUTPUT_DIR))
186
+ print("Done! LoRA adapter saved.")
187
+
188
+
189
+ if __name__ == "__main__":
190
+ parser = argparse.ArgumentParser()
191
+ parser.add_argument("--epochs", type=float, default=2.0)
192
+ parser.add_argument("--fresh", action="store_true")
193
+ arguments = parser.parse_args()
194
+ main(epochs=arguments.epochs, fresh=arguments.fresh)