06 — Categorization: rules and LLM
Duration: 12 min Prerequisites: chapter 05 (normalized DataFrame), Ollama installed.
The two-stage approach
Section titled “The two-stage approach”Our demo CSV ships with a perfectly good Category column, but many real exports don’t. Some banks give you only Description and Amount. So we want a robust path that:
- First, applies a YAML rules-engine (fast, deterministic, reviewable).
- Then, for rows still uncategorized, asks the LLM to guess.
flowchart LR
Row[Transaction row] --> Rules{Match a rule?}
Rules -->|yes| Category[Set category]
Rules -->|no| LLM[Ask LLM]
LLM --> Category
The rules file
Section titled “The rules file”csv-llm-shared/category_rules.yaml:
# Each entry: a category and a list of case-insensitive substrings.rules: Housing: - "rent payment" - "postbox rental" - "storagebox" - "garden care" - "willow street" Groceries: - "pantry" - "family foods" - "organic store" - "green grocers" - "kiwi basket" Dining: - "coffee room" - "spoon cafe" - "noodle house" - "bistro" - "sandwich bar" - "pizza works" Transport: - "cab network" - "bus card" - "shuttle" - "parking pay" Travel: - "lodge booking" - "hostel" - "path pass" - "queenstown" Business: - "designmarket" - "printworks" - "invoice client" - "deployhub" Utilities: - "fibre" - "internet co" Health: - "lab services" - "physiotherapy" - "careplus" Entertainment: - "puzzleroom" - "music hall" - "gamecorner" Fees: - "late payment fee" - "fx fee" Payments: - "credit card payment" Refunds: - "refund" - "return"The rules engine in csv-llm-shared/categorize.py
Section titled “The rules engine in csv-llm-shared/categorize.py”import refrom pathlib import Pathimport yaml
def load_rules(yaml_path: str | Path) -> list[tuple[str, re.Pattern]]: """Returns a list of (category, compiled-regex) pairs.""" raw = yaml.safe_load(Path(yaml_path).read_text(encoding="utf-8")) compiled = [] for category, patterns in raw.get("rules", {}).items(): for pat in patterns: compiled.append((category, re.compile(re.escape(pat), re.IGNORECASE))) return compiled
def categorize_row(description: str, rules) -> str | None: for category, regex in rules: if regex.search(description): return category return NoneA simple loop, ordered, case-insensitive. The first match wins.
The LLM fallback
Section titled “The LLM fallback”For uncategorized rows, we ask Ollama directly:
import ollama
CATEGORIES = [ "Housing", "Groceries", "Dining", "Transport", "Travel", "Business", "Utilities", "Health", "Entertainment", "Education", "Fees", "Payments", "Refunds",]
PROMPT = """You are a credit-card categorizer. Reply with EXACTLY ONE word from this list:{cats}
Merchant description: {desc}
Category:"""
def categorize_with_llm(description: str, model: str = "llama3.1:8b") -> str: response = ollama.chat( model=model, messages=[{"role": "user", "content": PROMPT.format( cats=", ".join(CATEGORIES), desc=description, )}], options={"temperature": 0.0}, ) answer = response["message"]["content"].strip().split()[0] return answer if answer in CATEGORIES else "Business"Three safeguards:
temperature=0.0for determinism.- We strip and take only the first word.
- If the model hallucinates a non-listed category, we default to
"Business".
Putting it together
Section titled “Putting it together”import syssys.path.insert(0, "csv-llm-shared")import categorize
rules = categorize.load_rules("csv-llm-shared/category_rules.yaml")
descriptions = [ "Rent Payment Willow Street", "Coastal Spoon Cafe", "Some Unknown New Merchant",]
for d in descriptions: cat = categorize.categorize_row(d, rules) if cat is None: cat = categorize.categorize_with_llm(d) + " (LLM)" print(f"{d:35s} → {cat}")Expected output:
Rent Payment Willow Street → HousingCoastal Spoon Cafe → DiningSome Unknown New Merchant → Business (LLM)Why a two-stage approach is the right choice
Section titled “Why a two-stage approach is the right choice”| Approach | Speed | Reproducibility | Coverage |
|---|---|---|---|
| Rules only | Very fast | 100% | Limited |
| LLM only | Slow | ~95% | Excellent |
| Rules + LLM fallback | Fast | ~99% | Excellent |
Rules handle the 80% of repeating merchants instantly. The LLM handles the 20% long-tail without breaking your latency budget.
Takeaways
Section titled “Takeaways”- A YAML rules-engine handles the bulk of categorization with zero LLM cost.
- The LLM is only called for unknown descriptions.
- A whitelist of valid categories + temperature 0 keeps results deterministic and auditable.
- The demo CSV is already categorized, but this layer is essential for real-world bank exports.