Text Generation
Transformers
Safetensors
English
qwen2
finance
banking
indian
upi
transaction-classification
qwen
fine-tuned
conversational
text-generation-inference
Instructions to use SahilGoel/indian-txn-classifier with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Transformers
How to use SahilGoel/indian-txn-classifier with Transformers:
# Use a pipeline as a high-level helper from transformers import pipeline pipe = pipeline("text-generation", model="SahilGoel/indian-txn-classifier") messages = [ {"role": "user", "content": "Who are you?"}, ] pipe(messages)# Load model directly from transformers import AutoTokenizer, AutoModelForCausalLM tokenizer = AutoTokenizer.from_pretrained("SahilGoel/indian-txn-classifier") model = AutoModelForCausalLM.from_pretrained("SahilGoel/indian-txn-classifier", device_map="auto") messages = [ {"role": "user", "content": "Who are you?"}, ] inputs = tokenizer.apply_chat_template( messages, add_generation_prompt=True, tokenize=True, return_dict=True, return_tensors="pt", ).to(model.device) outputs = model.generate(**inputs, max_new_tokens=40) print(tokenizer.decode(outputs[0][inputs["input_ids"].shape[-1]:])) - Notebooks
- Google Colab
- Kaggle
- Local Apps Settings
- vLLM
How to use SahilGoel/indian-txn-classifier with vLLM:
Install from pip and serve model
# Install vLLM from pip: pip install vllm # Start the vLLM server: vllm serve "SahilGoel/indian-txn-classifier" # Call the server using curl (OpenAI-compatible API): curl -X POST "http://localhost:8000/v1/chat/completions" \ -H "Content-Type: application/json" \ --data '{ "model": "SahilGoel/indian-txn-classifier", "messages": [ { "role": "user", "content": "What is the capital of France?" } ] }'Use Docker
docker model run hf.co/SahilGoel/indian-txn-classifier
- SGLang
How to use SahilGoel/indian-txn-classifier with SGLang:
Install from pip and serve model
# Install SGLang from pip: pip install sglang # Start the SGLang server: python3 -m sglang.launch_server \ --model-path "SahilGoel/indian-txn-classifier" \ --host 0.0.0.0 \ --port 30000 # Call the server using curl (OpenAI-compatible API): curl -X POST "http://localhost:30000/v1/chat/completions" \ -H "Content-Type: application/json" \ --data '{ "model": "SahilGoel/indian-txn-classifier", "messages": [ { "role": "user", "content": "What is the capital of France?" } ] }'Use Docker images
docker run --gpus all \ --shm-size 32g \ -p 30000:30000 \ -v ~/.cache/huggingface:/root/.cache/huggingface \ --env "HF_TOKEN=<secret>" \ --ipc=host \ lmsysorg/sglang:latest \ python3 -m sglang.launch_server \ --model-path "SahilGoel/indian-txn-classifier" \ --host 0.0.0.0 \ --port 30000 # Call the server using curl (OpenAI-compatible API): curl -X POST "http://localhost:30000/v1/chat/completions" \ -H "Content-Type: application/json" \ --data '{ "model": "SahilGoel/indian-txn-classifier", "messages": [ { "role": "user", "content": "What is the capital of France?" } ] }' - Docker Model Runner
How to use SahilGoel/indian-txn-classifier with Docker Model Runner:
docker model run hf.co/SahilGoel/indian-txn-classifier
Upload code/transaction_normalizer.py with huggingface_hub
Browse files- code/transaction_normalizer.py +237 -0
code/transaction_normalizer.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Stable, versioned transaction normalization boundary for TaxSage."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import math
|
| 6 |
+
import re
|
| 7 |
+
from datetime import date, datetime
|
| 8 |
+
from typing import Any
|
| 9 |
+
|
| 10 |
+
from pipeline.bank_classifier import ClassifiedTransaction, RawTransaction, classify_with_rules
|
| 11 |
+
|
| 12 |
+
RULESET_VERSION = "2026.07.17.1"
|
| 13 |
+
MAX_NORMALIZE_BATCH = 500
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class NormalizationError(ValueError):
|
| 17 |
+
"""Raised when a transaction cannot satisfy the normalization contract."""
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _first_nonempty(record: dict[str, Any], *keys: str) -> Any:
|
| 21 |
+
for key in keys:
|
| 22 |
+
value = record.get(key)
|
| 23 |
+
if value is not None and str(value).strip():
|
| 24 |
+
return value
|
| 25 |
+
return None
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _parse_timestamp(value: Any) -> tuple[date, str]:
|
| 29 |
+
text = str(value or "").strip()
|
| 30 |
+
if not text:
|
| 31 |
+
raise NormalizationError("transaction date is required")
|
| 32 |
+
|
| 33 |
+
try:
|
| 34 |
+
parsed_date = date.fromisoformat(text)
|
| 35 |
+
return parsed_date, parsed_date.isoformat()
|
| 36 |
+
except ValueError:
|
| 37 |
+
pass
|
| 38 |
+
|
| 39 |
+
try:
|
| 40 |
+
parsed_datetime = datetime.fromisoformat(text.replace("Z", "+00:00"))
|
| 41 |
+
return parsed_datetime.date(), parsed_datetime.isoformat()
|
| 42 |
+
except ValueError:
|
| 43 |
+
pass
|
| 44 |
+
|
| 45 |
+
for pattern in ("%d/%m/%Y", "%d-%m-%Y"):
|
| 46 |
+
try:
|
| 47 |
+
parsed_date = datetime.strptime(text, pattern).date()
|
| 48 |
+
return parsed_date, parsed_date.isoformat()
|
| 49 |
+
except ValueError:
|
| 50 |
+
continue
|
| 51 |
+
|
| 52 |
+
raise NormalizationError("transaction date is invalid")
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def _detect_channel(narration: str) -> str:
|
| 56 |
+
upper = narration.upper()
|
| 57 |
+
for channel in ("UPI", "NEFT", "IMPS", "RTGS", "ATM", "NACH", "ECS"):
|
| 58 |
+
if re.search(rf"\b{channel}\b", upper):
|
| 59 |
+
return channel
|
| 60 |
+
if re.search(r"\b(?:POS|ECOM|E-COM)\b", upper):
|
| 61 |
+
return "POS"
|
| 62 |
+
if re.search(r"\b(?:CARD|VISA|MASTERCARD|RUPAY)\b", upper):
|
| 63 |
+
return "CARD"
|
| 64 |
+
if re.search(r"\b(?:CHEQUE|CHQ)\b", upper):
|
| 65 |
+
return "CHEQUE"
|
| 66 |
+
if re.search(r"\b(?:INTERNET\s*BANKING|NETBANKING|I-BANK)\b", upper):
|
| 67 |
+
return "INTERNET_BANKING"
|
| 68 |
+
if re.search(r"\b(?:MOBILE\s*BANKING|MOB?BANK|M-BANK)\b", upper):
|
| 69 |
+
return "MOBILE_BANKING"
|
| 70 |
+
return "OTHER"
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def _detect_reversal(narration: str) -> bool:
|
| 74 |
+
return bool(
|
| 75 |
+
re.search(r"\b(?:REVERSAL|REVERSED|REFUND|RVSL|CHARGEBACK)\b", narration, re.IGNORECASE)
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def _detect_partial(narration: str) -> bool:
|
| 80 |
+
return bool(
|
| 81 |
+
re.search(
|
| 82 |
+
r"\b(?:PARTIAL|SPLIT|PART\s+\d+\s+OF\s+\d+)\b",
|
| 83 |
+
narration,
|
| 84 |
+
re.IGNORECASE,
|
| 85 |
+
)
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def detect_transaction_metadata(narration: str) -> dict[str, Any]:
|
| 90 |
+
"""Derive non-classifying metadata without database reads or writes."""
|
| 91 |
+
return {
|
| 92 |
+
"channel": _detect_channel(narration),
|
| 93 |
+
"is_reversal": _detect_reversal(narration),
|
| 94 |
+
"is_partial": _detect_partial(narration),
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def _classification_path(classified: ClassifiedTransaction) -> str:
|
| 99 |
+
rationale = classified.rationale.lower()
|
| 100 |
+
if rationale.startswith("merchant db:"):
|
| 101 |
+
return "merchant_db"
|
| 102 |
+
if rationale.startswith("known upi merchant evidence:"):
|
| 103 |
+
return "merchant_match"
|
| 104 |
+
if rationale.startswith("upi narration evidence:"):
|
| 105 |
+
return "narration_purpose"
|
| 106 |
+
if rationale.startswith("strong personal-transfer evidence"):
|
| 107 |
+
return "personal_transfer"
|
| 108 |
+
if rationale.startswith("matched rule:"):
|
| 109 |
+
return "regex_rule"
|
| 110 |
+
if rationale.startswith("manual override"):
|
| 111 |
+
return "manual_override"
|
| 112 |
+
return "unclassified"
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def _confidence_level(confidence: float) -> str:
|
| 116 |
+
if confidence >= 0.85:
|
| 117 |
+
return "HIGH"
|
| 118 |
+
if confidence >= 0.60:
|
| 119 |
+
return "MEDIUM"
|
| 120 |
+
return "LOW"
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def serialize_classified_transaction(
|
| 124 |
+
classified: ClassifiedTransaction,
|
| 125 |
+
*,
|
| 126 |
+
account: str = "default",
|
| 127 |
+
description: str | None = None,
|
| 128 |
+
) -> dict[str, Any]:
|
| 129 |
+
"""Serialize an existing classification with additive normalization metadata."""
|
| 130 |
+
narration = classified.raw.description if description is None else description
|
| 131 |
+
confidence = min(1.0, max(0.0, float(classified.confidence)))
|
| 132 |
+
rationale = classified.rationale or "No reliable classification evidence"
|
| 133 |
+
path = _classification_path(classified)
|
| 134 |
+
raw_date = classified.raw.date
|
| 135 |
+
raw_date_text = str(raw_date)
|
| 136 |
+
normalized_date = (
|
| 137 |
+
""
|
| 138 |
+
if raw_date_text == "NaT"
|
| 139 |
+
else raw_date.isoformat() if hasattr(raw_date, "isoformat") else raw_date_text
|
| 140 |
+
)
|
| 141 |
+
return {
|
| 142 |
+
"date": normalized_date,
|
| 143 |
+
"description": narration,
|
| 144 |
+
"amount": float(classified.raw.amount),
|
| 145 |
+
"type": classified.raw.type,
|
| 146 |
+
"category": classified.category or "unclassified",
|
| 147 |
+
"confidence": confidence,
|
| 148 |
+
"is_income": bool(classified.is_income),
|
| 149 |
+
"is_expense": bool(classified.is_expense),
|
| 150 |
+
"counterparty": classified.counterparty,
|
| 151 |
+
"rationale": rationale,
|
| 152 |
+
"account": account or "default",
|
| 153 |
+
"channel": _detect_channel(classified.raw.description),
|
| 154 |
+
"is_reversal": _detect_reversal(classified.raw.description),
|
| 155 |
+
"is_partial": _detect_partial(classified.raw.description),
|
| 156 |
+
"classification_path": path,
|
| 157 |
+
"ruleset_version": RULESET_VERSION,
|
| 158 |
+
"explain": {"path": path, "rationale": rationale},
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def normalize_transaction(
|
| 163 |
+
record: dict[str, Any],
|
| 164 |
+
*,
|
| 165 |
+
index: int = 0,
|
| 166 |
+
require_timestamp: bool = True,
|
| 167 |
+
) -> dict[str, Any]:
|
| 168 |
+
"""Normalize one validated transaction record into the stable TaxSage schema."""
|
| 169 |
+
if not isinstance(record, dict):
|
| 170 |
+
raise NormalizationError("transaction must be an object")
|
| 171 |
+
|
| 172 |
+
narration_value = _first_nonempty(record, "raw", "description")
|
| 173 |
+
narration = str(narration_value or "").strip()
|
| 174 |
+
if not narration:
|
| 175 |
+
raise NormalizationError("transaction narration is required")
|
| 176 |
+
|
| 177 |
+
amount_value = record.get("amount")
|
| 178 |
+
if isinstance(amount_value, bool):
|
| 179 |
+
raise NormalizationError("transaction amount must be finite")
|
| 180 |
+
try:
|
| 181 |
+
amount = float(amount_value)
|
| 182 |
+
except (TypeError, ValueError) as error:
|
| 183 |
+
raise NormalizationError("transaction amount must be finite") from error
|
| 184 |
+
if not math.isfinite(amount):
|
| 185 |
+
raise NormalizationError("transaction amount must be finite")
|
| 186 |
+
if amount < 0:
|
| 187 |
+
raise NormalizationError("transaction amount must be non-negative")
|
| 188 |
+
|
| 189 |
+
transaction_type = str(record.get("type", "")).strip().lower()
|
| 190 |
+
if transaction_type not in {"credit", "debit"}:
|
| 191 |
+
raise NormalizationError("transaction type must be credit or debit")
|
| 192 |
+
|
| 193 |
+
timestamp_value = _first_nonempty(record, "timestamp", "date")
|
| 194 |
+
if (timestamp_value is None or str(timestamp_value).strip() == "NaT") and not require_timestamp:
|
| 195 |
+
raw_date, normalized_timestamp = date.min, ""
|
| 196 |
+
else:
|
| 197 |
+
raw_date, normalized_timestamp = _parse_timestamp(timestamp_value)
|
| 198 |
+
raw = RawTransaction(
|
| 199 |
+
date=raw_date,
|
| 200 |
+
description=narration,
|
| 201 |
+
type=transaction_type,
|
| 202 |
+
amount=amount,
|
| 203 |
+
)
|
| 204 |
+
classified = classify_with_rules(raw, learn_merchants=False)
|
| 205 |
+
if classified is None:
|
| 206 |
+
classified = ClassifiedTransaction(
|
| 207 |
+
raw=raw,
|
| 208 |
+
category="unclassified",
|
| 209 |
+
confidence=0.30,
|
| 210 |
+
rationale="No reliable classification evidence",
|
| 211 |
+
is_income=False,
|
| 212 |
+
is_expense=transaction_type == "debit",
|
| 213 |
+
)
|
| 214 |
+
|
| 215 |
+
confidence = min(1.0, max(0.0, float(classified.confidence)))
|
| 216 |
+
rationale = classified.rationale or "No reliable classification evidence"
|
| 217 |
+
return {
|
| 218 |
+
"id": str(record.get("id", index)),
|
| 219 |
+
"raw": narration,
|
| 220 |
+
"merchant": classified.counterparty,
|
| 221 |
+
"category": classified.category or "unclassified",
|
| 222 |
+
"transaction_type": transaction_type.upper(),
|
| 223 |
+
"channel": _detect_channel(narration),
|
| 224 |
+
"amount": amount,
|
| 225 |
+
"normalized_timestamp": normalized_timestamp,
|
| 226 |
+
"is_reversal": _detect_reversal(narration),
|
| 227 |
+
"is_partial": _detect_partial(narration),
|
| 228 |
+
"confidence": confidence,
|
| 229 |
+
"confidence_level": _confidence_level(confidence),
|
| 230 |
+
"is_income": bool(classified.is_income),
|
| 231 |
+
"is_expense": bool(classified.is_expense),
|
| 232 |
+
"explain": {
|
| 233 |
+
"path": _classification_path(classified),
|
| 234 |
+
"rationale": rationale,
|
| 235 |
+
},
|
| 236 |
+
"ruleset_version": RULESET_VERSION,
|
| 237 |
+
}
|