SahilGoel commited on
Commit
2725543
·
verified ·
1 Parent(s): 4e4754e

Upload code/llm_classifier.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. code/llm_classifier.py +159 -0
code/llm_classifier.py ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Local LLM classifier using fine-tuned Qwen 0.5B model.
3
+
4
+ Acts as a targeted fallback — only invoked for transactions the
5
+ regex pipeline marks as unclassified or low-confidence (<0.70).
6
+ The model runs on CPU and is loaded once at module import time.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import logging
13
+ import sys
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+ from typing import Optional
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ PACKAGE_ROOT = Path(__file__).resolve().parent.parent
21
+ MODEL_PATH = PACKAGE_ROOT / "data" / "qwen-merged-0.5b"
22
+
23
+ SYSTEM_PROMPT = (
24
+ "You are a bank transaction classifier for Indian bank statements. "
25
+ "Given a raw transaction description, infer both its category and the actual company when evidence exists. "
26
+ "Respond with ONLY a JSON object: "
27
+ '{"category": "<category>", "company_name": "<company_or_null>", "is_income": false, "confidence": 0.0}. '
28
+ "Categories: salary, dividend, interest, rental, capital_gains, other_income, "
29
+ "food, grocery, shopping, bills, medical, insurance, tax_payment, credit_card, "
30
+ "personal_transfer, investment, trading_deposit, trading_credit, education, "
31
+ "travel, entertainment, donation, loan_emi, loan_repayment, cash_withdrawal, unclassified. "
32
+ "Use company_name=null for personal transfers or when the company is not supported by the description. "
33
+ "Credits to known employers = salary. UPI to person names = personal_transfer. "
34
+ "Toll/FASTag/NHAI/IHMCL payments = travel. "
35
+ "Refunds/reversals = original category. If truly unknown, category=unclassified, confidence=0.30."
36
+ )
37
+
38
+
39
+ @dataclass
40
+ class LLMClassification:
41
+ category: str
42
+ company_name: Optional[str]
43
+ is_income: bool
44
+ confidence: float
45
+ rationale: str = ""
46
+
47
+
48
+ class LocalQwenClassifier:
49
+ """Classifies transactions using the fine-tuned Qwen model."""
50
+
51
+ def __init__(self, model_path: Path = MODEL_PATH):
52
+ self._model = None
53
+ self._tokenizer = None
54
+ self._model_path = model_path
55
+ self._available = model_path.is_dir()
56
+
57
+ @property
58
+ def available(self) -> bool:
59
+ return self._available
60
+
61
+ def _ensure_loaded(self):
62
+ if self._model is not None:
63
+ return
64
+ try:
65
+ from transformers import AutoModelForCausalLM, AutoTokenizer
66
+ logger.info("Loading Qwen model from %s", self._model_path)
67
+ self._tokenizer = AutoTokenizer.from_pretrained(
68
+ str(self._model_path), trust_remote_code=True
69
+ )
70
+ self._model = AutoModelForCausalLM.from_pretrained(
71
+ str(self._model_path),
72
+ trust_remote_code=True,
73
+ torch_dtype="auto",
74
+ device_map="cpu",
75
+ )
76
+ self._model.eval()
77
+ logger.info("Qwen model loaded successfully")
78
+ except Exception as exc:
79
+ logger.warning("Failed to load Qwen model: %s", exc)
80
+ self._available = False
81
+
82
+ def classify(
83
+ self,
84
+ description: str,
85
+ txn_type: str = "debit",
86
+ ) -> Optional[LLMClassification]:
87
+ """Classify a single transaction description."""
88
+ if not self._available:
89
+ return None
90
+
91
+ self._ensure_loaded()
92
+ if self._model is None:
93
+ return None
94
+
95
+ prompt = (
96
+ f"### System:\n{SYSTEM_PROMPT}\n\n"
97
+ f"### Input:\n{description} (type: {txn_type})\n\n"
98
+ f"### Output:\n"
99
+ )
100
+
101
+ try:
102
+ inputs = self._tokenizer(prompt, return_tensors="pt", truncation=True, max_length=512)
103
+ outputs = self._model.generate(
104
+ **inputs,
105
+ max_new_tokens=80,
106
+ temperature=0.1,
107
+ do_sample=True,
108
+ pad_token_id=self._tokenizer.eos_token_id,
109
+ )
110
+ response = self._tokenizer.decode(outputs[0], skip_special_tokens=True)
111
+ # Extract JSON from the response
112
+ json_str = response.split("### Output:\n")[-1].strip()
113
+ # Remove any markdown code fences
114
+ if json_str.startswith("```"):
115
+ json_str = json_str.split("```")[1]
116
+ if json_str.startswith("json"):
117
+ json_str = json_str[4:]
118
+ parsed = json.loads(json_str)
119
+ return LLMClassification(
120
+ category=parsed.get("category", "unclassified"),
121
+ company_name=parsed.get("company_name"),
122
+ is_income=bool(parsed.get("is_income", False)),
123
+ confidence=float(parsed.get("confidence", 0.5)),
124
+ rationale=f"Qwen-0.5B fine-tuned",
125
+ )
126
+ except Exception as exc:
127
+ logger.debug("LLM classification failed for '%s': %s", description[:60], exc)
128
+ return None
129
+
130
+ def classify_batch(
131
+ self,
132
+ transactions: list[dict],
133
+ ) -> list[Optional[LLMClassification]]:
134
+ """Classify multiple transactions. Each dict must have 'description' and 'type'."""
135
+ results = []
136
+ for txn in transactions:
137
+ results.append(
138
+ self.classify(
139
+ description=str(txn.get("description", "")),
140
+ txn_type=str(txn.get("type", "debit")),
141
+ )
142
+ )
143
+ return results
144
+
145
+
146
+ # Singleton
147
+ _classifier: Optional[LocalQwenClassifier] = None
148
+
149
+
150
+ def get_llm_classifier() -> LocalQwenClassifier:
151
+ global _classifier
152
+ if _classifier is None:
153
+ _classifier = LocalQwenClassifier()
154
+ return _classifier
155
+
156
+
157
+ def classify_with_llm(description: str, txn_type: str = "debit") -> Optional[LLMClassification]:
158
+ """Convenience function for single classification."""
159
+ return get_llm_classifier().classify(description, txn_type)