Clean Net Revenue Retention (NRR) is the key growth driver for B2B SaaS. But manual analysis of hundreds of accounts consumes 20+ person-hours per month, and decisions are made based on intuition instead of data. We built an expansion event prediction system that increases NRR by 10-15% by precisely selecting the right timing and product. Our experience: 5+ years in AI solutions, 30+ implementations in B2B SaaS.
In a typical B2B SaaS company, account managers juggle hundreds of accounts, spending hours reviewing usage dashboards, support tickets, and contract dates. Expansion opportunities are often missed due to time constraints or misinterpretation of signals. Our AI system automates this: it analyzes dozens of features, identifies patterns, and delivers concise briefs with recommendations to the sales team. We can assess your project in 2 days — contact us for a consultation.
Why ML model beats rule-based approach?
Rule-based systems operate on simple rules like "if utilisation > 90% — offer expansion". They are simple but miss up to 40% of opportunities because they ignore signal combinations, trends, and complex patterns. An ML model, specifically gradient boosting, uncovers non-linear relationships and delivers 25% more accurate predictions.
| Criteria | Rule-based | ML (Gradient Boosting) |
|---|---|---|
| Precision@20% | 55-65% | 75-85% |
| Recall (coverage) | 30-40% | 50-65% |
| Adaptation to changes | Manual | Automatic (retraining) |
| Handling new features | Manual | Automatic feature importance |
Second table compares key production metrics:
| Metric | Typical Value |
|---|---|
| Precision@20% | 75-85% |
| Recall@20% | 65-75% |
| Lift over random selection | 2.0-2.5x |
How we build the account expansion model
We use gradient boosting (CatBoost/LightGBM) with a custom loss function that penalizes false positives more heavily than false negatives — sales team should not waste time on dead leads. Additionally, we employ LLM (Claude 3.5) to generate text briefs. The core module:
import pandas as pd
import numpy as np
from sklearn.ensemble import GradientBoostingClassifier
import shap
from anthropic import Anthropic
import json
class AccountExpansionPredictor:
"""Предсказание готовности аккаунта к расширению"""
def __init__(self):
self.model = GradientBoostingClassifier(
n_estimators=200, learning_rate=0.05, max_depth=4, random_state=42
)
self.llm = Anthropic()
def build_account_features(self, accounts: pd.DataFrame,
usage_data: pd.DataFrame,
support_data: pd.DataFrame) -> pd.DataFrame:
"""Feature engineering для expansion предсказания"""
features = accounts[['account_id']].copy()
# === Product Usage Signals ===
usage = usage_data.groupby('account_id').agg(
monthly_active_users=('user_id', pd.Series.nunique),
feature_breadth=('feature_name', pd.Series.nunique),
sessions_per_user=('session_id', 'count'),
advanced_features_used=('is_advanced_feature', 'sum'),
)
features = features.merge(usage, on='account_id', how='left')
# Тренд использования за последние 3 месяца
recent_usage = usage_data[
usage_data['date'] >= pd.Timestamp.now() - pd.DateOffset(months=3)
]
older_usage = usage_data[
(usage_data['date'] < pd.Timestamp.now() - pd.DateOffset(months=3)) &
(usage_data['date'] >= pd.Timestamp.now() - pd.DateOffset(months=6))
]
recent_counts = recent_usage.groupby('account_id')['session_id'].count()
older_counts = older_usage.groupby('account_id')['session_id'].count()
usage_trend = (recent_counts - older_counts) / (older_counts + 1)
features['usage_trend_3m'] = features['account_id'].map(usage_trend).fillna(0)
# === Account Health ===
features['days_as_customer'] = accounts.get('days_since_first_purchase', pd.Series([180]))
features['current_plan_tier'] = accounts.get('plan_tier', pd.Series([1])) # 1=basic, 2=pro, 3=enterprise
features['seats_utilization'] = (
accounts.get('active_users', 1) / accounts.get('licensed_seats', 1)
).clip(0, 1)
features['contract_months_remaining'] = accounts.get('contract_months_remaining', 12)
# === Support & Satisfaction ===
support = support_data.groupby('account_id').agg(
support_tickets_3m=('ticket_id', 'count'),
avg_csat=('csat_score', 'mean'),
has_critical_tickets=('priority', lambda x: (x == 'critical').any().astype(int))
)
features = features.merge(support, on='account_id', how='left')
features['support_tickets_3m'] = features['support_tickets_3m'].fillna(0)
features['avg_csat'] = features['avg_csat'].fillna(3.5)
# === Expansion Readiness Signals ===
features['seats_at_capacity'] = (features['seats_utilization'] > 0.90).astype(int)
features['power_user_count'] = usage_data[
usage_data['sessions_count'] > usage_data['sessions_count'].quantile(0.90)
].groupby('account_id')['user_id'].nunique().reindex(features['account_id']).fillna(0).values
return features.fillna(0)
def predict_expansion_opportunities(self, accounts: pd.DataFrame,
usage_data: pd.DataFrame,
support_data: pd.DataFrame) -> pd.DataFrame:
"""Список аккаунтов с высокой вероятностью расширения"""
features = self.build_account_features(accounts, usage_data, support_data)
feature_cols = [c for c in features.columns if c != 'account_id']
X = features[feature_cols]
probs = self.model.predict_proba(X)[:, 1]
features['expansion_probability'] = probs
features['expansion_potential_usd'] = self._estimate_expansion_value(features, accounts)
features['recommended_product'] = self._recommend_expansion_product(features)
# Приоритизация для sales team
features['priority_score'] = features['expansion_probability'] * np.log1p(features['expansion_potential_usd'])
return features.sort_values('priority_score', ascending=False)
def _estimate_expansion_value(self, features: pd.DataFrame,
accounts: pd.DataFrame) -> pd.Series:
"""Потенциальный ARR от расширения"""
base_arr = accounts.get('current_arr', pd.Series([10000]))
# Seats expansion
seats_expansion = (
features.get('seats_at_capacity', 0) *
features.get('power_user_count', 0) * 50 # $50/seat/month
)
# Plan upgrade
plan_upgrade_potential = (
(features.get('advanced_features_used', 0) > 5) &
(features.get('current_plan_tier', 1) < 2)
).astype(float) * base_arr * 0.5
return (seats_expansion * 12 + plan_upgrade_potential).fillna(0)
def _recommend_expansion_product(self, features: pd.DataFrame) -> pd.Series:
"""Рекомендуемый продукт для расширения"""
conditions = [
features.get('seats_at_capacity', pd.Series([0])) > 0,
features.get('feature_breadth', pd.Series([0])) < 5,
features.get('current_plan_tier', pd.Series([1])) == 1,
]
choices = ['seat_expansion', 'feature_add_on', 'plan_upgrade']
result = pd.Series(['general_expansion'] * len(features), index=features.index)
for cond, choice in zip(conditions, choices):
result = result.where(~cond, choice)
return result
def generate_expansion_brief(self, account: dict) -> str:
"""Бриф для account manager о сигналах расширения"""
response = self.llm.messages.create(
model="claude-3-5-sonnet-20241022",
max_tokens=200,
messages=[{
"role": "user",
"content": f"""Write a sales brief for account expansion in Russian.
Account: {account.get('company_name')}
Current ARR: ${account.get('current_arr', 0):,.0f}
Expansion probability: {account.get('expansion_probability', 0):.0%}
Key signals:
- Seats utilization: {account.get('seats_utilization', 0):.0%}
- Usage trend: {account.get('usage_trend_3m', 0):+.0%}
- Advanced features used: {account.get('advanced_features_used', 0)}
- Power users: {account.get('power_user_count', 0)}
Recommended expansion: {account.get('recommended_product', '')}
Estimated value: ${account.get('expansion_potential_usd', 0):,.0f} ARR
Write 2-3 sentences: what signals you see, what to propose, and how to frame the conversation."""
}]
)
return response.content[0].text
How often should the model be retrained?
The model should be retrained quarterly because customer behavior changes over time. We automate this via a CI/CD pipeline: every Sunday the model is validated on fresh data. If precision drops below 70%, a retrain is triggered. Unscheduled updates occur when the product line or pricing changes.
Details of the automatic retraining pipeline
The pipeline includes stages:
- Data collection from CRM and product analytics
- Feature engineering and validation
- Model training on a sliding window (6 months)
- Evaluation on a holdout set (1 month)
- A/B test against the current model
- Deployment via Kubernetes with canary release
What's included in the work
The system is delivered turnkey:
- Artifacts: trained model, inference pipeline, dashboard in Metabase/Grafana
- Integration: connectors to CRM (Salesforce, HubSpot) and data sources (Databricks, BigQuery)
- Documentation: Model card with results, instructions for CS team, API description
- Training: 2 workshops for sales and CS on interpreting results
- Support: 3-month warranty including bug fixes and retraining consultations
Process: from audit to deployment
- Data analysis: check quality, build feature store
- Prototyping: Baseline model and A/B test against rule-based
- Integration: connect real streams from CRM and product analytics
- Pilot: 2-week launch on 20% of accounts, measure Lift
- Production: deploy on Kubernetes with auto-retraining
Common implementation mistakes
- Lack of negative examples: taking all accounts without expansion causes class imbalance. We use undersampling and weighted loss.
- Ignoring temporal drift: a model trained on six-month-old data shows low precision on current data. Solution: sliding window retrain.
- Lack of interpretability: sales team doesn't trust a "black box". Our SHAP analysis and concise LLM briefs solve this.
Implementation example
For a B2B SaaS analytics platform with 5,000 accounts, we trained a model on 12 months of data. Three months after the pilot, the sales team worked the top 20% of accounts, and NRR increased by 12%. The system predicts not only upsell (moving to a more expensive plan) but also cross-sell (purchasing additional modules). Customer health scoring based on dozens of features identifies accounts with high expansion potential. Predicted RR helps plan growth.
We can assess your project in 2 days: analyze data, estimate ROI and timeline (typically 4-6 weeks to pilot). Contact us for a consultation — certified AI engineers will focus on your case.







