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
- River developer on retainer — online
StandardScalerzeros pre-normalized feature for 10K rows — running std=0 cold-start — rolling accuracy 0.69 — 3 weeks - Catalyst developer on retainer —
softmaxinsidemodel.forward()withCrossEntropyLoss— double-softmax — val_accuracy 0.01 (random chance) — 3 weeks - HPBandSter developer on retainer — BOHB
min_budget=10breaks multi-fidelity assumption on correlated features — val_AUC 0.74 — 3 weeks
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 →