Next Best Action Development: How to Boost ROAS 4–8x
How Next Best Action Transforms Marketing
You launch an email campaign, look at the dashboard — conversion drops, churn grows. Classic rule-based segments no longer work: customers are tired of spam, and the budget is melting away on ineffective touchpoints. We build an AI-powered Next Best Action (NBA) system that decides what to do with each customer right now — send a discount code, connect a manager, or do nothing. We rely on Reinforcement Learning and real-time business constraints.
In A/B testing, we consistently see LTV increase by 20–40% and churn decrease by 20–30%. On one project, savings reached 1.5 million rubles per month by cutting ineffective communications. The ML action model is trained on historical data to personalize communications and optimize marketing. The system delivers real-time recommendations for each customer.
Problems NBA Solves
Excessive communication. Without NBA, you bombard everyone with identical emails. A customer who just bought receives a discount offer — irritation, churn. We implement a penalty for frequency: after 2 touches in 7 days, the action probability decreases by a factor of 0.4.
Wrong channel. Push notifications are cheap, but for B2B clients they are useless — a manager call is needed. For mass-market, a 50-ruble call is unprofitable. NBA ranks actions by expected ROI, considering channel cost.
Static rules. Rules like "if LTV > 1000 — call" become outdated in a month. NBA retrains on fresh data — reward functions are tied to actual revenue.
How We Build NBA: Contextual Bandit with RL
The core is a contextual bandit. For each action, we train a separate LogisticRegression model that predicts the probability of high reward (LTV delta) given the context. Multiple actions may be candidates; we select the one maximizing expected ROI.
import numpy as np
from sklearn.linear_model import LogisticRegression
from sklearn.preprocessing import StandardScaler
import pandas as pd
class NextBestActionEngine:
"""Contextual bandit for selecting the next action"""
def __init__(self):
self.actions = {
'email_discount': {'cost': 10, 'type': 'outbound'},
'push_notification': {'cost': 1, 'type': 'outbound'},
'call_outbound': {'cost': 50, 'type': 'outbound'},
'show_banner': {'cost': 0.5, 'type': 'inbound'},
'offer_upgrade': {'cost': 0, 'type': 'inbound'},
'no_action': {'cost': 0, 'type': 'none'}
}
self.action_models = {} # Separate model for each action
self.scaler = StandardScaler()
def train(self, history: pd.DataFrame):
"""
history: user_id, context_features..., action_taken, reward (LTV delta)
"""
X = self.scaler.fit_transform(
history.drop(columns=['user_id', 'action_taken', 'reward']).fillna(0)
)
for action in self.actions:
mask = history['action_taken'] == action
if mask.sum() < 50:
continue
# Model: under which context does the action yield high reward
action_rewards = history.loc[mask, 'reward']
y = (action_rewards > action_rewards.median()).astype(int)
model = LogisticRegression(C=1.0, max_iter=200)
model.fit(X[mask], y)
self.action_models[action] = model
def recommend_action(self, user: dict,
business_constraints: dict = None) -> dict:
"""Select optimal action"""
context_features = self._extract_context(user)
X = self.scaler.transform([context_features])
action_scores = {}
for action, meta in self.actions.items():
# Apply business constraints
if business_constraints:
if meta['cost'] > business_constraints.get('max_action_cost', 1000):
continue
if (action in business_constraints.get('blocked_actions', [])):
continue
# Predict reward
if action in self.action_models:
reward_prob = self.action_models[action].predict_proba(X)[0][1]
else:
reward_prob = 0.3 # Prior for untrained actions
# Expected ROI = expected revenue - cost
expected_revenue = reward_prob * user.get('expected_clv', 100)
expected_roi = expected_revenue - meta['cost']
# Fatigue from communications
if meta['type'] == 'outbound':
communications_7d = user.get('communications_7d', 0)
fatigue_penalty = max(0, 1 - 0.3 * communications_7d)
expected_roi *= fatigue_penalty
action_scores[action] = {
'expected_roi': expected_roi,
'reward_probability': reward_prob,
'cost': meta['cost']
}
# Select action with maximum ROI
best_action = max(action_scores, key=lambda x: action_scores[x]['expected_roi'])
return {
'recommended_action': best_action,
'expected_roi': action_scores[best_action]['expected_roi'],
'reward_probability': action_scores[best_action]['reward_probability'],
'all_scores': action_scores
}
def _extract_context(self, user: dict) -> list:
return [
user.get('days_since_last_purchase', 30),
user.get('total_orders', 0),
user.get('ltv', 0),
user.get('churn_probability', 0.5),
user.get('email_open_rate', 0.2),
user.get('age_months', 12),
user.get('avg_order_value', 100),
user.get('support_tickets_30d', 0),
user.get('website_visits_7d', 0),
user.get('communications_7d', 0)
]
How to Orchestrate NBA in Real Time?
NBA is effective only when it reacts instantly. A customer abandons a cart — within 5 minutes they receive a push with a personalized offer. We build an event-driven pipeline: Kafka captures events (view, cart, ticket), enriches the profile, calls recommend_action, and sends a command to the executor.
class NBAOrchestrator:
"""Real-time orchestration of actions"""
def __init__(self, nba_engine: NextBestActionEngine):
self.engine = nba_engine
self.action_executors = {}
def process_customer_event(self, event_type: str,
user: dict) -> dict:
"""Event-triggered processing"""
# Event context affects NBA
event_modifiers = {
'cart_abandoned': {'churn_probability': +0.2, 'urgency': 'high'},
'product_viewed_3x': {'intent_score': 0.8},
'price_page_viewed': {'price_sensitivity': +0.3},
'support_ticket_opened': {'satisfaction': -0.5}
}
enriched_user = {**user}
if event_type in event_modifiers:
for key, delta in event_modifiers[event_type].items():
if isinstance(delta, (int, float)):
enriched_user[key] = enriched_user.get(key, 0) + delta
else:
enriched_user[key] = delta
# Constraints based on event
constraints = {}
if event_type == 'cart_abandoned':
constraints['allowed_actions'] = ['email_discount', 'push_notification', 'show_banner']
decision = self.engine.recommend_action(enriched_user, constraints)
# Log for training
self._log_decision(user['user_id'], event_type, decision)
return decision
def _log_decision(self, user_id: str, event: str, decision: dict):
"""Record decision for offline training"""
import json
import datetime
log_entry = {
'timestamp': datetime.datetime.now().isoformat(),
'user_id': user_id,
'trigger_event': event,
'action_taken': decision['recommended_action'],
'expected_roi': decision['expected_roi']
}
# In production: write to Kafka/DB
print(f"NBA: {json.dumps(log_entry)}")
Why NBA with RL Is Better Than Rules?
| Parameter | Rule-based approach | Our NBA (Contextual Bandit) |
|---|---|---|
| Adaptability | Weekly review | Real-time, online learning |
| Context consideration | 2–3 features | 10+ features, event modifiers |
| Optimal "do nothing" | No | Yes (up to 40% of customers) |
| ROAS | 2–3x | 4–8x |
| Churn reduction | 10–15% | 20–30% |
The difference is 3x in ROAS. And the key insight: for 30–40% of customers, the best action is nothing. That's not an error; it's budget savings and churn reduction.
How NBA Boosts ROAS 4–8x
The system learns from historical data, identifying patterns: under which context which action yields maximum ROI. The reward function penalizes excessive communications and rewards actions leading to purchase. A/B tests consistently show a stable increase in ROAS compared to rules.
Typical Mistakes When Implementing NBA
- Ignoring cost per action — leads to choosing expensive channels without budget oversight.
- Absence of a "do nothing" signal — the model will always spam, increasing churn.
- Insufficient history for training — <100k records yields high variance.
Implementation Timeline
| Stage | Duration | Key result |
|---|---|---|
| Analytics and data collection | 1–2 weeks | Defined action list, cleaned data |
| Model design | 1 week | Selected features, reward function, constraints |
| Development and training | 2–4 weeks | Working bandit on 100k+ records |
| Integration and testing | 2 weeks | A/B test launched |
| Deployment and monitoring | 1 week | Production with p99 latency <100ms |
Basic MVP with 5–6 actions and a contextual bandit: 4–8 weeks. Full system with RL and event orchestration: 10–16 weeks. Cost is calculated individually based on your data volume and number of channels.
What's Included (Deliverables)
- Audit of current marketing data and channels.
- Design of action model (up to 10 actions).
- Training of contextual bandit on historical data.
- Development of event orchestrator (Kafka/PubSub).
- Integration with CRM, ESP, push services.
- A/B testing vs rule-based segments.
- Documentation and team training.
- Quality guarantee: one-month trial run with metric monitoring.
Our team has years of experience developing AI solutions for marketing. Our engineers are authors of open-source RL libraries. We provide a guarantee on model correctness for 3 months after launch.
Get a consultation right now. Contact us for a free audit of your data. Order end-to-end NBA development and see first results in 4 weeks.







