Skip to content

06 — Categorization: rules and LLM

Duration: 12 min Prerequisites: chapter 05 (normalized DataFrame), Ollama installed.

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:

  1. First, applies a YAML rules-engine (fast, deterministic, reviewable).
  2. 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

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 re
from pathlib import Path
import 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 None

A simple loop, ordered, case-insensitive. The first match wins.

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:

  1. temperature=0.0 for determinism.
  2. We strip and take only the first word.
  3. If the model hallucinates a non-listed category, we default to "Business".
import sys
sys.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 → Housing
Coastal Spoon Cafe → Dining
Some 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”
ApproachSpeedReproducibilityCoverage
Rules onlyVery fast100%Limited
LLM onlySlow~95%Excellent
Rules + LLM fallbackFast~99%Excellent

Rules handle the 80% of repeating merchants instantly. The LLM handles the 20% long-tail without breaking your latency budget.

  • 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.

Next: Plotly dashboards →