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

Upload code/augment_training_data.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. code/augment_training_data.py +378 -0
code/augment_training_data.py ADDED
@@ -0,0 +1,378 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Build privacy-safe local Qwen training rows from bank statements.
3
+
4
+ The generated dataset stays under ``autotaxfiler/data`` (gitignored). Source
5
+ paths, taxpayer identities, and transaction reference IDs are not persisted in
6
+ new rows or passed to the fine-tuning formatter.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import hashlib
13
+ import json
14
+ import re
15
+ import sys
16
+ import tempfile
17
+ from collections import Counter, defaultdict
18
+ from dataclasses import asdict, dataclass, field
19
+ from pathlib import Path
20
+ from typing import Callable, Iterable
21
+
22
+
23
+ PACKAGE_ROOT = Path(__file__).resolve().parent.parent
24
+ if str(PACKAGE_ROOT) not in sys.path:
25
+ sys.path.insert(0, str(PACKAGE_ROOT))
26
+
27
+ try:
28
+ from .bank_classifier import RawTransaction, _parse_statement
29
+ from .company_inference import infer_company_name
30
+ from .pii_shield import mask_pii
31
+ from .training_schema import NON_INCOME_CATEGORIES, SUPPORTED_CATEGORIES
32
+ except ImportError:
33
+ from pipeline.bank_classifier import RawTransaction, _parse_statement
34
+ from pipeline.company_inference import infer_company_name
35
+ from pipeline.pii_shield import mask_pii
36
+ from pipeline.training_schema import NON_INCOME_CATEGORIES, SUPPORTED_CATEGORIES
37
+
38
+
39
+ DEFAULT_DATA_ROOT = PACKAGE_ROOT.parent / "data"
40
+ DEFAULT_OUTPUT = PACKAGE_ROOT / "data" / "training_data.json"
41
+ _STATEMENT_SUFFIXES = {".csv", ".pdf", ".xls", ".xlsx"}
42
+ _PROVISIONAL_CATEGORIES = {
43
+ "expense_uncategorized",
44
+ "transfer",
45
+ "unclassified",
46
+ "unclassified_credit",
47
+ }
48
+ _PERSONAL_CATEGORIES = {
49
+ "family", "friends", "personal_transfer", "rental", "staff_salary", "transfer"
50
+ }
51
+ _LONG_REFERENCE_TOKEN = re.compile(
52
+ r"(?<![A-Z0-9])(?=[A-Z0-9]*\d)[A-Z0-9]{8,}(?![A-Z0-9])",
53
+ re.IGNORECASE,
54
+ )
55
+ _LONG_DIGIT_RUN = re.compile(r"\d{6,}")
56
+ _CONTEXT_REFERENCE_PATTERNS = (
57
+ (re.compile(r"\b(CAM/)[A-Z0-9-]{4,}", re.IGNORECASE), r"\1<ID>"),
58
+ (re.compile(r"(?<=/)\d{4,}(?=/|$)"), "<ID>"),
59
+ (re.compile(r"\bWAR_NO:\s*[A-Z0-9-]{4,}", re.IGNORECASE), "WAR_NO: <ID>"),
60
+ )
61
+ _VPA = re.compile(r"([A-Z0-9._-]{2,})@([A-Z]{2,20})", re.IGNORECASE)
62
+ _SAFE_PERSONAL_SEGMENT_WORDS = {
63
+ "ACH", "ATM", "BANK", "BILL", "BOOK", "BROADBAND", "CAB", "CAFE",
64
+ "CAPITAL", "DEPOSIT", "FUND", "INCOME", "MANDATE", "MOBILE", "MUTUAL", "NACH",
65
+ "PETROL", "PREMIUM", "PUMP", "RECHARGE", "REFUND", "SIP", "SUBSCRIPTION",
66
+ "CARD", "CASH", "CHARITY", "COLLEGE", "CONSULTING", "CREDIT", "DEBIT",
67
+ "DIVIDEND", "DONATION", "EDUCATION", "ELECTRICITY", "EMI", "ENTERTAINMENT",
68
+ "FEE", "FITNESS", "FLIGHT", "FOOD", "GAS", "GROCERY", "HDFC", "HOSPITAL",
69
+ "HOTEL", "ICICI", "IDFC", "IMPS", "INSURANCE", "INTEREST", "INVOICE",
70
+ "KOTAK", "LOAN", "MEDICAL", "NEFT", "PAYMENT", "PHARMACY", "POS", "RENT",
71
+ "RESTAURANT", "REVERSAL", "RTGS", "SALARY", "SBI", "SCHOOL", "SHOPPING",
72
+ "STORE", "TAX", "TRADING", "TRANSFER", "TRAVEL", "TUITION", "UPI", "VEHICLE",
73
+ "WATER", "WDL",
74
+ }
75
+
76
+
77
+ @dataclass
78
+ class AugmentationStats:
79
+ discovered_files: int = 0
80
+ parsed_files: int = 0
81
+ failed_files: int = 0
82
+ parsed_transactions: int = 0
83
+ existing_matches: int = 0
84
+ low_confidence: int = 0
85
+ provisional_labels: int = 0
86
+ unsupported_categories: int = 0
87
+ duplicate_instances: int = 0
88
+ conflicting_keys: int = 0
89
+ added_rows: int = 0
90
+ company_labels: int = 0
91
+ failure_types: dict[str, int] = field(default_factory=dict)
92
+
93
+
94
+ def normalize_description(description: str) -> str:
95
+ """Return a stable uppercase transaction description."""
96
+ return re.sub(r"\s+", " ", str(description)).strip().upper()
97
+
98
+
99
+ def sanitize_training_description(
100
+ description: str,
101
+ *,
102
+ category: str,
103
+ company_name: str | None = None,
104
+ ) -> str:
105
+ """Remove identifying/reference data while preserving merchant evidence."""
106
+ sanitized = normalize_description(str(description))
107
+ for pattern, replacement in _CONTEXT_REFERENCE_PATTERNS:
108
+ sanitized = pattern.sub(replacement, sanitized)
109
+ sanitized = _LONG_REFERENCE_TOKEN.sub("<ID>", sanitized)
110
+ sanitized = _LONG_DIGIT_RUN.sub("<ID>", sanitized)
111
+ sanitized = normalize_description(mask_pii(sanitized))
112
+
113
+ company_tokens = re.findall(r"[A-Z]+", normalize_description(company_name or ""))
114
+ company_words = set(company_tokens)
115
+ company_label = " ".join(company_tokens)
116
+ company_identity = "".join(sorted(company_words))
117
+ allowed_words = _SAFE_PERSONAL_SEGMENT_WORDS | company_words
118
+ segments = []
119
+ for segment in sanitized.split("/"):
120
+ segment = segment.strip()
121
+ if not segment:
122
+ segments.append(segment)
123
+ continue
124
+ canonical_vpa = _VPA.fullmatch(segment)
125
+ if (
126
+ canonical_vpa
127
+ and company_identity
128
+ and canonical_vpa.group(1).upper() == company_identity
129
+ ):
130
+ segments.append(segment)
131
+ continue
132
+ compact_segment = re.sub(r"[^A-Z]", "", segment)
133
+ if company_words and any(word in compact_segment for word in company_words):
134
+ purpose_words = [
135
+ word
136
+ for word in re.findall(r"[A-Z]+", segment)
137
+ if word in _SAFE_PERSONAL_SEGMENT_WORDS and word not in company_words
138
+ ]
139
+ segments.append(" ".join([company_label, *purpose_words]))
140
+ continue
141
+ placeholder_vpa = re.fullmatch(r"<PERSON>@([A-Z]{2,20})", segment)
142
+ if placeholder_vpa:
143
+ segments.append(segment)
144
+ continue
145
+ vpa = _VPA.search(segment)
146
+ if vpa:
147
+ identity = company_identity or "<PERSON>"
148
+ segments.append(f"{identity}@{vpa.group(2).upper()}")
149
+ continue
150
+ visible_text = re.sub(r"<[^>]+>", " ", segment)
151
+ words = set(re.findall(r"[A-Z]+", visible_text))
152
+ if not words:
153
+ segments.append("<ID>" if "<ID>" in segment else segment)
154
+ else:
155
+ safe_words = [
156
+ word
157
+ for word in re.findall(r"[A-Z]+", visible_text)
158
+ if word in allowed_words
159
+ ]
160
+ segments.append(" ".join(safe_words) if safe_words else "<PERSON>")
161
+ return "/".join(segments)
162
+
163
+
164
+ class UnsupportedStatementError(ValueError):
165
+ """Raised when a discovered statement format has no safe parser."""
166
+
167
+
168
+ _JASPER_EXCEL_HEADERS = {
169
+ "s no.", "value date", "transaction date", "cheque number",
170
+ "transaction remarks", "withdrawal amount(inr)", "deposit amount(inr)",
171
+ "balance(inr)",
172
+ }
173
+
174
+
175
+ def _has_supported_excel_schema(path: Path) -> bool:
176
+ import pandas as pd
177
+
178
+ dataframe = pd.read_excel(path, header=None)
179
+ for row_index in range(min(25, len(dataframe))):
180
+ values = {
181
+ str(value).strip().lower()
182
+ for value in dataframe.iloc[row_index].tolist()
183
+ if pd.notna(value)
184
+ }
185
+ if _JASPER_EXCEL_HEADERS.issubset(values):
186
+ return True
187
+ return False
188
+
189
+
190
+ def parse_training_statement(path_string: str) -> list[RawTransaction]:
191
+ """Parse only statement layouts whose debit/credit semantics are known."""
192
+ path = Path(path_string)
193
+ # PDF parsing is supported via pymupdf in bank_classifier._parse_pdf
194
+ # Allow PDFs through — the existing _parse_statement handles them
195
+ if path.suffix.lower() in {".xls", ".xlsx"} and not _has_supported_excel_schema(path):
196
+ raise UnsupportedStatementError("unsupported Excel statement schema")
197
+ return _parse_statement(str(path))
198
+
199
+
200
+ def discover_statement_files(data_root: Path) -> list[Path]:
201
+ """Find unique likely bank statements without exposing their paths."""
202
+ candidates = []
203
+ if not data_root.exists():
204
+ return candidates
205
+ for path in data_root.rglob("*"):
206
+ if not path.is_file() or path.suffix.lower() not in _STATEMENT_SUFFIXES:
207
+ continue
208
+ lowered = str(path).lower()
209
+ if not any(token in lowered for token in ("bank", "statement", "passbook", "pass book", "account")):
210
+ continue
211
+ candidates.append(path)
212
+
213
+ unique: dict[str, Path] = {}
214
+ for path in candidates:
215
+ digest = hashlib.sha256(path.read_bytes()).hexdigest()
216
+ unique.setdefault(digest, path)
217
+ return [unique[digest] for digest in sorted(unique)]
218
+
219
+
220
+ def _default_classifier():
221
+ from pipeline.classifier import ClassificationPipeline
222
+ from pipeline.classifier.stages import (
223
+ CatchAllStage,
224
+ DescriptionRuleStage,
225
+ MerchantDBStage,
226
+ RegexRuleStage,
227
+ UPIHeuristicStage,
228
+ )
229
+
230
+ return ClassificationPipeline([
231
+ MerchantDBStage(),
232
+ UPIHeuristicStage(),
233
+ DescriptionRuleStage(),
234
+ RegexRuleStage(),
235
+ CatchAllStage(),
236
+ ])
237
+
238
+
239
+ def _existing_key(row: dict) -> tuple[str, str]:
240
+ company_name = row.get("company_name") or row.get("merchant") or row.get("counterparty")
241
+ return (
242
+ sanitize_training_description(
243
+ row.get("description", ""),
244
+ category=row.get("category", "unclassified"),
245
+ company_name=company_name,
246
+ ),
247
+ row.get("type", ""),
248
+ )
249
+
250
+
251
+ def augment_rows(
252
+ existing_rows: list[dict],
253
+ statement_paths: Iterable[str | Path],
254
+ *,
255
+ parse_statement: Callable[[str], list[RawTransaction]] = parse_training_statement,
256
+ classifier=None,
257
+ min_confidence: float = 0.85,
258
+ ) -> tuple[list[dict], AugmentationStats]:
259
+ """Extract unique, sanitized, high-confidence labels not already present."""
260
+ classifier = classifier or _default_classifier()
261
+ paths = list(statement_paths)
262
+ stats = AugmentationStats(discovered_files=len(paths))
263
+ existing_keys = {
264
+ key
265
+ for row in existing_rows
266
+ if (
267
+ "<PERSON>" not in (key := _existing_key(row))[0]
268
+ or row.get("path") == "statement_augmentation"
269
+ )
270
+ }
271
+ existing_raw_keys = {
272
+ (normalize_description(row.get("description", "")), row.get("type", ""))
273
+ for row in existing_rows
274
+ }
275
+ candidates: dict[tuple[str, str], list[dict]] = defaultdict(list)
276
+ failures: Counter[str] = Counter()
277
+
278
+ for path in paths:
279
+ try:
280
+ transactions = parse_statement(str(path))
281
+ except Exception as error:
282
+ stats.failed_files += 1
283
+ failures[type(error).__name__] += 1
284
+ continue
285
+
286
+ stats.parsed_files += 1
287
+ stats.parsed_transactions += len(transactions)
288
+ for transaction in transactions:
289
+ raw_key = (normalize_description(transaction.description), transaction.type)
290
+ if raw_key in existing_raw_keys:
291
+ stats.existing_matches += 1
292
+ continue
293
+ result = classifier.classify(transaction, learn=False)
294
+ if result is None or result.confidence < min_confidence:
295
+ stats.low_confidence += 1
296
+ continue
297
+ if result.category not in SUPPORTED_CATEGORIES:
298
+ stats.unsupported_categories += 1
299
+ continue
300
+ if result.category in _PROVISIONAL_CATEGORIES:
301
+ stats.provisional_labels += 1
302
+ continue
303
+
304
+ company_name = infer_company_name(
305
+ transaction.description,
306
+ category=result.category,
307
+ explicit_name=result.counterparty,
308
+ )
309
+ description = sanitize_training_description(
310
+ transaction.description,
311
+ category=result.category,
312
+ company_name=company_name,
313
+ )
314
+ key = (description, transaction.type)
315
+ if not description:
316
+ continue
317
+ if key in existing_keys:
318
+ stats.existing_matches += 1
319
+ continue
320
+
321
+ candidates[key].append({
322
+ "description": description,
323
+ "category": result.category,
324
+ "type": transaction.type,
325
+ "is_income": (
326
+ False
327
+ if result.category in NON_INCOME_CATEGORIES
328
+ else bool(result.is_income)
329
+ ),
330
+ "company_name": company_name,
331
+ "confidence": round(float(result.confidence), 4),
332
+ "path": "statement_augmentation",
333
+ })
334
+
335
+ added = []
336
+ for key in sorted(candidates):
337
+ rows = candidates[key]
338
+ labels = {(row["category"], row["type"]) for row in rows}
339
+ if len(labels) != 1:
340
+ stats.conflicting_keys += 1
341
+ continue
342
+ stats.duplicate_instances += max(0, len(rows) - 1)
343
+ added.append(max(rows, key=lambda row: row["confidence"]))
344
+
345
+ stats.failure_types = dict(sorted(failures.items()))
346
+ stats.added_rows = len(added)
347
+ stats.company_labels = sum(row["company_name"] is not None for row in added)
348
+ return added, stats
349
+
350
+
351
+ def _atomic_write_json(path: Path, data: list[dict]) -> None:
352
+ path.parent.mkdir(parents=True, exist_ok=True)
353
+ with tempfile.NamedTemporaryFile(
354
+ "w", encoding="utf-8", dir=path.parent, delete=False
355
+ ) as handle:
356
+ json.dump(data, handle, indent=2, ensure_ascii=False)
357
+ handle.write("\n")
358
+ temporary = Path(handle.name)
359
+ temporary.replace(path)
360
+
361
+
362
+ def main(*, data_root: Path, output: Path, dry_run: bool = False) -> AugmentationStats:
363
+ existing_rows = json.loads(output.read_text(encoding="utf-8")) if output.exists() else []
364
+ statements = discover_statement_files(data_root)
365
+ added_rows, stats = augment_rows(existing_rows, statements)
366
+ if not dry_run:
367
+ _atomic_write_json(output, [*existing_rows, *added_rows])
368
+ print(json.dumps(asdict(stats), sort_keys=True))
369
+ return stats
370
+
371
+
372
+ if __name__ == "__main__":
373
+ parser = argparse.ArgumentParser()
374
+ parser.add_argument("--data-root", type=Path, default=DEFAULT_DATA_ROOT)
375
+ parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT)
376
+ parser.add_argument("--dry-run", action="store_true")
377
+ arguments = parser.parse_args()
378
+ main(data_root=arguments.data_root, output=arguments.output, dry_run=arguments.dry_run)