On-Device TF-IDF and Logistic Regression for Auto-Categorising Bank Transactions

When developers think about machine learning on Android, the default recommendation is to reach for an established inference framework: TensorFlow Lite, ONNX Runtime, or PyTorch Mobile.

These frameworks are remarkable engineering feats, but for a privacy-first, offline personal finance app, they carry immense operational baggage:

  1. Binary Bloat: Native C++ shared libraries (.so) compiled for arm64-v8a, armeabi-v7a, and x86_64 add between 15 MB and 35 MB to your APK size.
  2. Cold Start Latency: Initializing a native runtime and binding JNI memory buffers adds perceptible overhead to app launch.
  3. Architectural Complexity: Managing native memory allocations across Android lifecycle events increases the risk of native segfaults.

When I set out to classify incoming transactional SMS alerts into expense categories (such as Groceries, Food & Dining, Utilities, and Transportation) entirely offline, I realized something critical:

Financial SMS classification is not a computer vision problem or a generative LLM problem. It is a bounded, high-signal text classification task.

Instead of bundling a multi-megabyte C++ runtime, I built an inference engine in pure Kotlin in under 270 lines of code (LocalSmsIntelligenceEngine.kt). It runs in less than 0.5 milliseconds with zero native dependencies and zero network calls.

Here is how it works.

The Architectural Comparison

flowchart TD subgraph HeavyRuntime["The Standard Heavy ML Pipeline"] direction TB Input1["Raw SMS Text"] --> JNI["JNI Bridge & Native C++ Bindings"] JNI --> TFLite["TensorFlow Lite / ONNX Engine: 30 MB Shared Libraries (.so) Native Memory Buffers"] TFLite --> Output1["Expense Category"] end subgraph PureKotlinRuntime["FinLedger Pure Kotlin ML Engine"] direction TB Input2["Raw SMS Text"] --> Tokenizer["Fast Tokenizer Regex: [\\w]+|[₹$]"] Tokenizer --> TFIDF["Sublinear TF-IDF Vectorizer: 1 + ln(tf) with L2 Normalization"] TFIDF --> LinearModel["Multiclass Logistic Regression: Dot Product + Softmax in Pure Kotlin"] LinearModel --> Output2["Expense Category (Sub-0.5ms Latency)"] end

By keeping the entire mathematical pipeline inside the standard Kotlin JVM runtime, FinLedger avoids JNI boundary marshaling entirely. The application package remains tiny, memory allocations stay within standard Android GC management, and execution is trivially multiplatform.

Step 1: Tokenization and Sublinear TF-IDF

A banking notification consists of currency symbols, numbers, merchant names, and operational verbs:

"Debited Rs. 450.00 at SWIGGY BANGALORE on 22-OCT-26"

Token Extraction

The engine breaks raw text into normalized lowercase tokens using a pre-compiled regular expression targeting word characters and currency symbols ([\w]+|[₹$]). This preserves currency markers while stripping punctuation and whitespace without creating intermediate string allocations.

Sublinear Term Frequency (TF)

In standard term frequency counting, a word appearing five times receives five times the weight of a word appearing once. In short SMS texts, repetitive terms (like bank disclaimers) can easily drown out critical signal words like "Swiggy" or "Fuel".

The engine applies sublinear term frequency scaling:

  • If a term appears count times, its term frequency weight is calculated as 1 + ln(count).
  • If a term does not appear, its weight is zero.

This dampens the influence of repeated words and prevents boilerplate bank text from skewing classification.

Inverse Document Frequency (IDF) Weighting

The engine maintains a pre-trained vocabulary dictionary and IDF weight table loaded once during application startup from a compact JSON asset (sms_classifier_model.json).

Each token matching the vocabulary has its sublinear TF multiplied by its pre-calculated IDF value:

  • Common terms across all bank messages (like "account", "debited", "transaction") receive low IDF weights.
  • Highly informative domain keywords (like "supermarket", "petrol", "pharmacy", "hospital") receive high IDF weights.

L2 Vector Normalization

To prevent longer SMS messages from having artificially larger vector magnitudes than short alerts, the resulting feature vector is normalized to unit Euclidean length (L2 norm):

  • Each component is divided by the square root of the sum of squared weights.
  • This projects every text vector onto the surface of a unit hypersphere, making dot products directly comparable regardless of message length.
flowchart LR A["Raw Tokens"] --> B["Count Occurrences"] B --> C["Sublinear Scaling: 1 + ln(count)"] C --> D["IDF Multiplication: TF * IDF[token]"] D --> E["L2 Normalization: v / sqrt(sum(v_i^2))"] E --> F["Normalized Feature Vector"]

Step 2: Calibrated Multiclass Logistic Regression

Once the normalized sparse feature vector is constructed, classification is a direct matrix-vector multiplication.

For each supported category (such as Food & Dining, Groceries, Fuel, Shopping, Healthcare, Utilities):

  1. Compute the dot product between the normalized input vector and the pre-trained category weight vector.
  2. Add the category intercept (bias).
  3. The result is the raw linear logit for that category.

Softmax Probability Distribution

To turn raw logits into calibrated confidence scores between 0.0 and 1.0, the engine applies the Softmax function:

  • Calculate the exponential of each logit.
  • Sum all exponentials across all categories.
  • Divide each category's exponential by the total sum.

This produces a normalized probability distribution where all category scores sum to 1.0.

The class with the highest probability is selected. If the winning probability exceeds our confidence threshold (such as 75%), the category is assigned automatically. If the model is uncertain, the transaction falls back to user confirmation, honoring our Never Guess Principle.

Performance Profile in Production

Running this pure Kotlin pipeline on physical Android hardware yields remarkable real-world numbers:

Metric Heavy Native ML Runtime FinLedger Pure Kotlin Engine
Binary Size Overhead +25 MB to +35 MB < 120 KB (JSON asset)
Native .so Dependencies 4 architectures required Zero (Pure Kotlin)
Initialization Time 40 ms – 120 ms < 4 ms
Inference Latency 3.5 ms – 8.0 ms < 0.45 ms
Network Requests 0 (if offline model used) 0 (Strictly local)
JNI Overhead Present on every call None

Key Takeaway

Before reaching for massive machine learning runtimes that inflate your application size, analyze the mathematical shape of your problem.

Text classification for structured financial alerts does not require a deep neural network running on native C++ binaries. A mathematically disciplined, pure Kotlin implementation of sublinear TF-IDF and logistic regression delivers sub-millisecond inference, zero APK bloat, and total privacy for your users.

FinLedger is a privacy-first, offline-first personal finance application for Android that models bank accounts, credit cards, and cash without double-counting.

Explore FinLedger on Google Play: https://play.google.com/store/apps/details?id=com.devanshufinance.app