Vowpal Wabbit developer on retainer: user_id:12345678 treated as categorical name hash not numeric value — numerically similar users hash to unrelated buckets — val_AUC 0.73 vs LightGBM 0.82 — 3 weeks — Vowpal Wabbit developer on monthly retainer

vowpalwabbit 9.6.x • Python 3.11 • online binary classification • 500K-row click-through rate dataset • 15 features • vw --passes 1 --bit_precision 24 --loss_function logistic --link logistic -f vw_model.bin • user_id formatted as |user user_id:12345678 • VW hashes string "user_id:12345678" as categorical name • users 12345678 and 12345679 hash to unrelated buckets • 73% of user_ids appear once in 500K rows • val_AUC=0.73 • LightGBM frequency-encoded baseline: AUC=0.82 • developer checks bit_precision, loss_function, link for 3 weeks • fix: frequency-encode user_id • |user user_id:{freq_count} • val_AUC 0.73→0.79 • 1.5h retainer engagement

Northfield VW ML Ltd. built an online binary classification pipeline using vowpalwabbit 9.6.x to predict click-through rate on 500,000 ad impressions. The dataset had 15 features including user_id — an 8-digit integer identifier in [100000, 99999999] assigned to registered users. The team formatted user_id as |user user_id:12345678, intending to give VW the user’s identity as a feature. The VW model achieved val_AUC=0.73. A LightGBM baseline with user_id frequency-encoded (each user replaced by their count of appearances in the training data) achieved val_AUC=0.82. The developer spent three weeks adjusting --bit_precision from 20 to 26, checking the loss function, the link function, and the namespace assignments before engaging a Vowpal Wabbit retainer developer.

The VW command and input format at the time of the engagement:

# Northfield VW ML Ltd. — online CTR binary classification
# vowpalwabbit 9.6.x, Python 3.11

import pandas as pd
from vowpalwabbit import pyvw

# VW model configuration
vw = pyvw.vw(
    '--passes 1 '
    '--bit_precision 24 '       # 2^24 = 16,777,216 hash buckets
    '--loss_function logistic '
    '--link logistic '
    '-f vw_model.bin'
)

df = pd.read_csv('northfield_ctr_500k.csv')
# 500K rows, 15 features:
#   user_id:        int in [100000, 99999999] — 8-digit user identifier
#   ad_id:          int in [1000, 999999]     — 6-digit ad identifier
#   publisher_id:   int in [1, 50000]         — publisher site identifier
#   hour_of_day:    int in [0, 23]
#   day_of_week:    int in [0, 6]
#   user_age_group: int in [0, 5]             — binned age group
#   ad_category:    int in [0, 29]            — IAB ad category
#   ...
# Target: clicked (0/1)

def to_vw_format(row, label=None):
    label_str = f"{1 if label else -1} " if label is not None else ""
    return (
        f"{label_str}"
        # Namespace 'user': user-level features
        f"|user user_id:{row['user_id']} "          # ← PROBLEM: 8-digit integer as name
        f"user_age_group:{row['user_age_group']} "
        # Namespace 'ad': ad-level features
        f"|ad ad_id:{row['ad_id']} "
        f"ad_category:{row['ad_category']} "
        # Namespace 'ctx': context features
        f"|ctx hour:{row['hour_of_day']} dow:{row['day_of_week']} "
        f"pub:{row['publisher_id']}"
    )

# ← Problem: VW feature hashing
# When VW sees "user_id:12345678" in a namespace, it hashes the ENTIRE TOKEN
# "user_id:12345678" using murmur3 to find the weight bucket.
# The integer 12345678 is part of the feature NAME STRING, not a numeric value.
# user_id:12345678 → murmur3("user_id:12345678") = 0x3A71F2E1 → bucket 7,340,009
# user_id:12345679 → murmur3("user_id:12345679") = 0xB94C0D8F → bucket 12,038,575
# These two users have completely unrelated weight buckets.
# No smoothness, no "this user is similar to that user" signal from adjacent IDs.
#
# 500K rows, estimated 1M registered users → average 0.5 appearances per user_id.
# Actual distribution:
#   73% of user_ids appear ONCE in the 500K training rows
#   19% appear 2–5 times
#    8% appear 6+ times
# A user_id bucket appearing once receives exactly 1 gradient update.
# Starting from weight=0, 1 update of step_size=0.1 → weight=±0.1 or 0.
# The model learns essentially nothing from unique-appearance user_ids.

# Training loop
for _, row in df.iterrows():
    example = to_vw_format(row, label=row['clicked'])
    vw.learn(example)

vw.finish()

# Evaluation on held-out 100K rows
vw_eval = pyvw.vw(
    '--bit_precision 24 '
    '--loss_function logistic '
    '--link logistic '
    '-t '           # test-only mode
    '-i vw_model.bin'
)
from sklearn.metrics import roc_auc_score
df_test = pd.read_csv('northfield_ctr_100k_test.csv')
preds = []
for _, row in df_test.iterrows():
    example = to_vw_format(row)  # no label
    preds.append(vw_eval.predict(example))
vw_eval.finish()

auc_vw = roc_auc_score(df_test['clicked'].values, preds)
print(f"VW val_AUC: {auc_vw:.4f}")
# VW val_AUC: 0.7301

# LightGBM baseline with frequency-encoded user_id
import lightgbm as lgb
from sklearn.model_selection import train_test_split
freq_map = df['user_id'].value_counts().to_dict()
df['user_id_freq'] = df['user_id'].map(freq_map).fillna(0)
df_test['user_id_freq'] = df_test['user_id'].map(freq_map).fillna(0)

features = ['user_id_freq', 'ad_id', 'publisher_id', 'hour_of_day',
            'day_of_week', 'user_age_group', 'ad_category']
lgb_model = lgb.LGBMClassifier(n_estimators=200, num_leaves=63, random_state=42)
lgb_model.fit(df[features], df['clicked'])
lgb_preds = lgb_model.predict_proba(df_test[features])[:, 1]
auc_lgb = roc_auc_score(df_test['clicked'], lgb_preds)
print(f"LightGBM (freq-encoded user_id) val_AUC: {auc_lgb:.4f}")
# LightGBM (freq-encoded user_id) val_AUC: 0.8201
# 9-point gap — VW is not capturing user_id information at all

Why VW’s feature hashing treats user_id:12345678 as a categorical name, not a numeric value

Vowpal Wabbit’s feature representation format uses a colon to separate a feature name from its numeric value: feature_name:value. The value after the colon is a floating-point number that multiplies the weight stored in the hashed bucket for feature_name. This is a true numeric representation: age:25 and age:30 both hash to the same bucket for “age” and their contributions to the dot product are 25 × w_age and 30 × w_age respectively — different magnitudes, same weight. Smooth, learnable.

When the developer wrote user_id:12345678, they may have intended this numeric interpretation: “the feature named user_id has value 12345678.” But VW’s parser does not work this way for integer-valued feature names. VW hashes the entire feature token — the string "user_id:12345678" in full — using murmur3 to find the weight bucket. The integer 12345678 is treated as part of the feature name string. user_id:12345679 hashes a different string to a completely different bucket. Users 12345678 and 12345679 have no relationship in VW’s model: they are as unrelated as user_id:00000001 and user_id:99999999.

This is equivalent to one-hot encoding with a random permutation. With 24-bit precision (16M buckets) and 8-digit user IDs (up to 99.9M possible values), there is some hash collision, but it is not meaningful: two users who happen to collide to the same bucket share a weight by accident, not because they are similar. For the 73% of user_ids that appear only once in the 500K training rows, the model receives a single gradient update on each bucket and learns nothing reliable. The LightGBM baseline, by contrast, frequency-encodes user_id before training: each user is replaced by the integer count of how many times they appear in the training data. This gives LightGBM a smooth, learnable signal: high-frequency users (thousands of appearances) have reliable prediction histories, while rare users cluster at low frequency counts where the model can learn a consistent “cold-start” prior.

# Diagnostic: audit VW feature representation for user_id
# vowpalwabbit 9.6.x — verify categorical vs numeric treatment

from vowpalwabbit import pyvw
import pandas as pd

# Check VW audit output to see how user_id is hashed
vw_audit = pyvw.vw(
    '--bit_precision 24 '
    '--loss_function logistic '
    '--link logistic '
    '--audit '
    '--quiet'
)

# Two users with adjacent integer IDs
example_a = "1 |user user_id:12345678 |ad ad_id:5001"
example_b = "1 |user user_id:12345679 |ad ad_id:5001"

pred_a = vw_audit.learn(example_a)
pred_b = vw_audit.learn(example_b)

# VW audit output format: "namespace^feature_name:hash_bucket:value:weight"
# Example audit output:
#   user^user_id:12345678:7340009:1:0.0        ← bucket 7340009
#   user^user_id:12345679:12038575:1:0.0       ← bucket 12038575
#   ad^ad_id:5001:3214801:1:0.0
#
# user_id:12345678 → bucket 7340009
# user_id:12345679 → bucket 12038575
# These are 4.7M buckets apart in a 16M-bucket hash table.
# No correlation between adjacent user IDs.
# Any smoothness in user behavior is completely lost.

# Check user_id frequency distribution in training data
df_train = pd.read_csv('northfield_ctr_500k.csv')
user_counts = df_train['user_id'].value_counts()
print(f"Total unique user_ids in 500K rows: {len(user_counts):,}")
# Total unique user_ids in 500K rows: 412,837

print(f"\nuser_id frequency distribution:")
print(f"  Appear exactly once:    {(user_counts == 1).sum():,} ({(user_counts == 1).mean()*100:.1f}%)")
print(f"  Appear 2–5 times:       {((user_counts >= 2) & (user_counts <= 5)).sum():,} ({((user_counts >= 2) & (user_counts <= 5)).mean()*100:.1f}%)")
print(f"  Appear 6–20 times:      {((user_counts >= 6) & (user_counts <= 20)).sum():,} ({((user_counts >= 6) & (user_counts <= 20)).mean()*100:.1f}%)")
print(f"  Appear 21+ times:       {(user_counts >= 21).sum():,} ({(user_counts >= 21).mean()*100:.1f}%)")
# user_id frequency distribution:
#   Appear exactly once:    301,671 (73.1%)
#   Appear 2–5 times:        78,424 (19.0%)
#   Appear 6–20 times:       25,113 (6.1%)
#   Appear 21+ times:         7,629  (1.8%)
#
# 73% of user_id buckets in VW receive exactly 1 gradient update.
# Starting from weight=0, with learning_rate=0.5 (VW default):
# After 1 update on a positive example: weight ≈ lr * gradient ≈ 0.1–0.2
# After 1 update on a negative example: weight ≈ -0.1 to -0.2
# The model predicts slightly above or below 0.5 for each unique user.
# Zero predictive signal extracted from the user_id field.
vw_audit.finish()

Fix: frequency-encode user_id and pass as numeric value |user user_id:{freq_count}

The fix is to replace the raw integer user_id with an online frequency count: for each row in the stream, look up how many times this user_id has appeared in the data so far, and pass that count as the numeric value: |user user_id:{freq_count}. In this format, VW hashes the string "user_id" (a single, fixed token) to find the weight bucket, and multiplies that weight by freq_count (the numeric value). The model learns a single weight that determines how much to trust high-frequency users versus low-frequency users — a smooth, learnable relationship. The 73% of users who appear once contribute 1 × w_user_id to the prediction. Users who appear 100 times contribute 100 × w_user_id. This frequency signal is not a user identity — it cannot distinguish individual users — but it captures the most statistically reliable CTR pattern: frequent users have more predictable click behavior than first-time users.

With frequency encoding, the VW model achieves val_AUC=0.79 — a 0.06-point improvement over the broken categorical representation. The residual gap of 0.03 versus LightGBM’s 0.82 is attributable to LightGBM’s ability to combine frequency with other features non-linearly (e.g., high-frequency users in certain ad categories have especially high CTR), which the linear online VW model cannot capture in a single pass.

# Fix: frequency-encode user_id — pass as numeric value not categorical name
# vowpalwabbit 9.6.x, Python 3.11

import pandas as pd
from vowpalwabbit import pyvw
from collections import defaultdict
from sklearn.metrics import roc_auc_score

# Online frequency table: accumulate user_id counts during streaming
user_freq_table = defaultdict(int)

vw_fixed = pyvw.vw(
    '--passes 1 '
    '--bit_precision 24 '
    '--loss_function logistic '
    '--link logistic '
    '-f vw_model_fixed.bin'
)

def to_vw_format_fixed(row, freq_table, label=None):
    label_str = f"{1 if label else -1} " if label is not None else ""

    # ← Fix: use freq_count as the numeric value for user_id
    # VW hashes "user_id" (fixed string) to one bucket.
    # The numeric value freq_count multiplies the weight for that bucket.
    # High-frequency users (freq_count=500) produce 500x the contribution
    # of a first-time user (freq_count=1). One learnable weight for all users.
    user_id = row['user_id']
    freq_count = freq_table[user_id]  # times seen before this row (0 for first occurrence)
    freq_count_plus_1 = freq_count + 1  # use count+1 so first occurrence contributes 1 not 0

    return (
        f"{label_str}"
        f"|user user_id:{freq_count_plus_1} "   # ← FIXED: numeric value, not name hash
        f"user_age_group:{row['user_age_group']} "
        f"|ad ad_id:{row['ad_id']} "
        f"ad_category:{row['ad_category']} "
        f"|ctx hour:{row['hour_of_day']} dow:{row['day_of_week']} "
        f"pub:{row['publisher_id']}"
    )

df_train = pd.read_csv('northfield_ctr_500k.csv')

for _, row in df_train.iterrows():
    user_id = row['user_id']
    # Build frequency in example BEFORE learning (avoid label leakage into freq count)
    example = to_vw_format_fixed(row, user_freq_table, label=row['clicked'])
    vw_fixed.learn(example)
    user_freq_table[user_id] += 1   # increment AFTER scoring

vw_fixed.finish()

# Evaluation on held-out test set
vw_eval_fixed = pyvw.vw(
    '--bit_precision 24 '
    '--loss_function logistic '
    '--link logistic '
    '-t '
    '-i vw_model_fixed.bin'
)

df_test = pd.read_csv('northfield_ctr_100k_test.csv')
preds_fixed = []
for _, row in df_test.iterrows():
    # For test-time: use frequency from training data as a lookup
    example = to_vw_format_fixed(row, user_freq_table)  # no label
    preds_fixed.append(vw_eval_fixed.predict(example))

vw_eval_fixed.finish()

auc_fixed = roc_auc_score(df_test['clicked'].values, preds_fixed)
print(f"Fixed VW val_AUC: {auc_fixed:.4f}")
# Fixed VW val_AUC: 0.7903

# ---- After fix: summary ----
# VW (user_id:12345678 as categorical name hash):  val_AUC = 0.73  ← broken
# LightGBM (frequency-encoded user_id):            val_AUC = 0.82  ← baseline
# VW (user_id:{freq_count} as numeric value):      val_AUC = 0.79  ← fixed

# ---- Hour breakdown ----
# Triage:     0.5h — val_AUC 0.73 vs LightGBM 0.82; VW audit output run;
#             user_id bucket activations inspected; 73% single-appearance buckets found
# Root cause: 0.5h — categorical name hash vs numeric value distinction identified;
#             user_id:12345678 hashing confirmed via audit output;
#             frequency encoding rationale explained
# Fix:        0.25h — online frequency table implemented; format updated to user_id:{freq_count}
# Validation: 0.25h — val_AUC 0.73→0.79; 1.5h total

Related retainer posts


Track retainer hours without a client portal

HourTab turns your time-tracker CSV into a public URL your client bookmarks. No login. No portal. CSV in, URL out.

See pricing →