Introduction

In the realm of machine learning, models are exceptionally good at finding patterns. They can tell you that users who attend weekly webinars are 40% less likely to churn. However, ML models are fundamentally blind to causation. If you automatically offer a 20% pricing discount to every user who skips a webinar, you are acting on a correlation, not a causal lever. You might be wasting margin on users who were never going to churn in the first place.

To build enterprise-grade pricing and retention engines, we must move beyond predictive correlation and embrace Causal Inference and Uplift Modeling. This article explores how to architect an AI system that distinguishes between spurious correlations and true causal drivers. We will build a multi-agent LangGraph application that uses RAG to analyze historical pricing experiments, isolates the true causal impact of discounts, and recommends targeted interventions.

Disclaimer: This POC uses simulated statistical outputs and historical experiment data for educational purposes.

The Correlation Trap in Churn Prediction

Standard churn prediction models output a probability of churn based on features like login frequency, support tickets, and usage volume.

Distinguishing Correlation from Causation

To distinguish the two in pricing experiments, we rely on three methodologies:

  1. Randomized Controlled Trials (A/B Testing): The gold standard. By randomly assigning pricing discounts, we ensure the treatment (discount) is independent of user characteristics.

  2. Propensity Score Matching (PSM): For observational data where randomization wasn't possible, we match users who received a discount with statistically identical users who did not, isolating the effect of the price change.

  3. Uplift Modeling: Instead of predicting who will churn, we predict who will change their behavior because of the intervention. We segment users into four quadrants: "Sure Things" (won't churn anyway), "Lost Causes" (will churn anyway), "Sleeping Dogs" (might churn if bothered), and "Persuadables" (will stay only if given a discount).

Real-Time Use Case: B2B SaaS Pricing Interventions

Scenario: A B2B SaaS platform notices a segment of mid-tier users showing signs of disengagement. The sales team wants to offer a 15% renewal discount to prevent churn. System Behavior:

  1. RAG Agent: Retrieves historical A/B test results and PSM analyses regarding "mid-tier discount elasticity."

  2. Causal Analyst Agent: Evaluates the retrieved data to calculate the Average Treatment Effect (ATE). It identifies that while discounts correlate with retention, the causal uplift is only positive for users with < 3 active licenses. For users with > 3 licenses, the discount has zero causal impact (they are "Sure Things").

  3. Strategy Agent: Recommends a targeted discount only for the < 3 license cohort, saving the company thousands in unnecessary margin erosion.

Technology Stack Overview

Project Architecture

causal-churn-engine/
├── backend/
│   ├── app/
│   │   ├── main.py
│   │   ├── config.py
│   │   ├── models/
│   │   │   └── state.py
│   │   ├── agents/
│   │   │   ├── experiment_retriever.py
│   │   │   ├── causal_analyst.py
│   │   │   └── intervention_strategist.py
│   │   ├── graph/
│   │   │   └── workflow.py
│   │   └── rag/
│   │       └── ingest.py
│   └── requirements.txt
├── frontend/
│   ├── src/
│   │   ├── App.jsx
│   │   └── components/
│   │       └── CausalDashboard.jsx
│   └── package.json
└── README.md

Step-by-Step POC Implementation

1. Backend Setup and Configuration

backend/requirements.txt

fastapi
uvicorn
langgraph
langchain
langchain-openai
langchain-community
chromadb
sentence-transformers
numpy
python-dotenv
pydantic

backend/app/config.py

import os
from dotenv import load_dotenv

load_dotenv()

class Settings:
    OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
    CHROMA_PATH = "./chroma_db"

settings = Settings()

2. Building the Experiment Knowledge Base (RAG)

We ingest historical experiment logs, A/B test results, and causal methodology reports into our vector store.

backend/app/rag/ingest.py

from langchain_core.documents import Document
from langchain_community.vectorstores import Chroma
from langchain_community.embeddings import HuggingFaceEmbeddings
from langchain_text_splitters import RecursiveCharacterTextSplitter

def get_experiment_docs():
    return [
        Document(
            page_content="Experiment 2023-Q3: 15% Discount on Renewal. Overall Retention Uplift (ATE): +2.1%. Propensity Score Matching confirmed statistical significance (p<0.05).",
            metadata={"segment": "General", "treatment": "15% Discount", "ate": 2.1}
        ),
        Document(
            page_content="Cohort Analysis: Mid-tier users with >3 active licenses. Discount Elasticity: 0.0. Causal Impact of 15% discount on retention: Null. These users exhibit high product dependency; pricing is not the churn driver.",
            metadata={"segment": "Mid-Tier High-Usage", "treatment": "15% Discount", "ate": 0.0}
        ),
        Document(
            page_content="Cohort Analysis: Mid-tier users with <=3 active licenses. Discount Elasticity: 0.8. Causal Impact of 15% discount on retention: +8.5%. Highly price-sensitive segment.",
            metadata={"segment": "Mid-Tier Low-Usage", "treatment": "15% Discount", "ate": 8.5}
        ),
        Document(
            page_content="Correlation Warning: High support ticket volume correlates with churn (r=0.65). However, A/B test offering discounts to high-ticket users showed 0% causal uplift. Root cause is product friction, not price.",
            metadata={"segment": "High-Support", "treatment": "Discount", "ate": 0.0}
        )
    ]

def ingest_data():
    docs = get_experiment_docs()
    splitter = RecursiveCharacterTextSplitter(chunk_size=500, chunk_overlap=50)
    split_docs = splitter.split_documents(docs)
    
    embeddings = HuggingFaceEmbeddings(model_name="all-MiniLM-L6-v2")
    vector_store = Chroma.from_documents(
        documents=split_docs,
        embedding=embeddings,
        persist_directory="./chroma_db"
    )
    print("Experiment knowledge base ingested.")
    return vector_store

3. Defining the LangGraph State

backend/app/models/state.py

from typing import TypedDict, List, Optional, Annotated
from langgraph.graph import add_messages

class CausalState(TypedDict):
    messages: Annotated[list, add_messages]
    user_profile: dict
    correlated_risk_score: float
    retrieved_experiments: List[dict]
    causal_analysis: str
    average_treatment_effect: float
    final_intervention: str

4. The Multi-Agent Workflow

Experiment Retriever (RAG)

backend/app/agents/experiment_retriever.py

from langchain_community.vectorstores import Chroma
from langchain_community.embeddings import HuggingFaceEmbeddings

def retriever_node(state):
    embeddings = HuggingFaceEmbeddings(model_name="all-MiniLM-L6-v2")
    db = Chroma(persist_directory="./chroma_db", embedding_function=embeddings)
    
    # Query based on user segment and proposed treatment
    segment = state["user_profile"].get("segment", "General")
    query = f"causal impact and ATE of discount for {segment}"
    docs = db.similarity_search(query, k=2)
    
    state["retrieved_experiments"] = [
        {"content": d.page_content, "metadata": d.metadata} for d in docs
    ]
    return state

Causal Analyst Agent

This agent uses an LLM to interpret the statistical experiment logs and isolate the true causal effect (ATE) from mere correlation. backend/app/agents/causal_analyst.py

from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
from app.config import settings

llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)

prompt = ChatPromptTemplate.from_template("""
You are a Causal Inference Data Scientist. 
Analyze the historical experiment data to determine the true Average Treatment Effect (ATE) of a 15% discount for the given user segment.
Ignore spurious correlations. Focus only on causal uplift from A/B tests or Propensity Score Matching.

User Segment Context: {segment}
Historical Experiment Data:
{experiments}

Return a JSON string with:
- "causal_analysis": "Brief explanation of why the discount works or fails causally"
- "ate": The numeric Average Treatment Effect (uplift in retention %)
""")

def causal_analyst_node(state):
    segment = state["user_profile"].get("segment", "General")
    exp_text = "\n".join([e["content"] for e in state["retrieved_experiments"]])
    
    chain = prompt | llm
    response = chain.invoke({"segment": segment, "experiments": exp_text})
    
    # Simplified parsing for POC
    state["causal_analysis"] = response.content
    # Extract ATE from metadata of the most relevant doc for simulation
    relevant_ate = state["retrieved_experiments"][0]["metadata"].get("ate", 0.0) if state["retrieved_experiments"] else 0.0
    state["average_treatment_effect"] = relevant_ate
    
    return state

Intervention Strategist

backend/app/agents/intervention_strategist.py

def strategist_node(state):
    ate = state["average_treatment_effect"]
    risk = state["correlated_risk_score"]
    
    if ate > 5.0:
        state["final_intervention"] = "APPROVE: 15% Discount. Causal uplift is significant. User is a 'Persuadable'."
    elif ate > 0.0 and risk > 0.7:
        state["final_intervention"] = "HOLD: Offer Customer Success outreach instead of discount. Causal price sensitivity is low; churn is likely driven by product friction."
    else:
        state["final_intervention"] = "REJECT DISCOUNT: User is a 'Sure Thing' or 'Lost Cause'. Discount will result in pure margin erosion with 0% causal retention uplift."
        
    return state

Orchestrating with LangGraph

backend/app/graph/workflow.py

from langgraph.graph import StateGraph, END
from app.models.state import CausalState
from app.agents.experiment_retriever import retriever_node
from app.agents.causal_analyst import causal_analyst_node
from app.agents.intervention_strategist import strategist_node
from langgraph.checkpoint.sqlite import SqliteSaver

def create_workflow():
    workflow = StateGraph(CausalState)
    
    workflow.add_node("retrieve", retriever_node)
    workflow.add_node("analyze", causal_analyst_node)
    workflow.add_node("strategize", strategist_node)
    
    workflow.set_entry_point("retrieve")
    workflow.add_edge("retrieve", "analyze")
    workflow.add_edge("analyze", "strategize")
    workflow.add_edge("strategize", END)
    
    memory = SqliteSaver.from_conn_string(":memory:")
    return workflow.compile(checkpointer=memory)

causal_graph = create_workflow()

5. FastAPI Endpoints

backend/app/main.py

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from app.graph.workflow import causal_graph
from app.rag.ingest import ingest_data
import uuid

app = FastAPI(title="Causal Churn Engine")

class UserRequest(BaseModel):
    user_id: str
    segment: str
    license_count: int
    support_tickets: int
    correlated_risk_score: float # Output from standard ML model

@app.post("/ingest")
def ingest():
    ingest_data()
    return {"status": "Experiment Data Updated"}

@app.post("/evaluate")
def evaluate(req: UserRequest):
    thread_id = str(uuid.uuid4())
    config = {"configurable": {"thread_id": thread_id}}
    
    initial_state = {
        "messages": [{"role": "user", "content": "Evaluate pricing intervention"}],
        "user_profile": {
            "segment": req.segment,
            "licenses": req.license_count,
            "tickets": req.support_tickets
        },
        "correlated_risk_score": req.correlated_risk_score
    }
    
    try:
        result = causal_graph.invoke(initial_state, config=config)
        return {
            "user_id": req.user_id,
            "ml_risk_score": req.correlated_risk_score,
            "causal_analysis": result["causal_analysis"],
            "ate": result["average_treatment_effect"],
            "intervention": result["final_intervention"]
        }
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))

Frontend Implementation with React

frontend/src/App.jsx

import React, { useState } from 'react';

function App() {
  const [formData, setFormData] = useState({
    user_id: "USR-992",
    segment: "Mid-Tier High-Usage",
    license_count: 5,
    support_tickets: 12,
    correlated_risk_score: 0.82
  });
  const [result, setResult] = useState(null);

  const handleSubmit = async () => {
    try {
      const res = await fetch('http://localhost:8000/evaluate', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(formData),
      });
      const data = await res.json();
      setResult(data);
    } catch (err) {
      console.error(err);
    }
  };

  return (
    <div className="p-10 max-w-4xl mx-auto font-sans">
      <h1 className="text-3xl font-bold mb-6 text-gray-800">Causal vs. Correlated Churn Engine</h1>
      
      <div className="grid grid-cols-2 gap-4 mb-6 bg-gray-50 p-4 rounded-lg">
        <select value={formData.segment} onChange={e => setFormData({...formData, segment: e.target.value})} className="p-2 border rounded">
          <option value="Mid-Tier High-Usage">Mid-Tier High-Usage (>3 Licenses)</option>
          <option value="Mid-Tier Low-Usage">Mid-Tier Low-Usage (<=3 Licenses)</option>
          <option value="High-Support">High-Support Volume</option>
        </select>
        <input type="number" placeholder="Risk Score (0-1)" value={formData.correlated_risk_score} onChange={e => setFormData({...formData, correlated_risk_score: parseFloat(e.target.value)})} className="p-2 border rounded"/>
      </div>
      
      <button onClick={handleSubmit} className="bg-indigo-600 text-white px-6 py-2 rounded-lg hover:bg-indigo-700">
        Run Causal Analysis
      </button>

      {result && (
        <div className="mt-8 space-y-4">
          <div className="bg-yellow-50 border-l-4 border-yellow-400 p-4">
            <h2 className="font-bold text-yellow-800">Standard ML Prediction (Correlation)</h2>
            <p>Churn Risk: <span className="font-mono font-bold">{(result.ml_risk_score * 100).toFixed(1)}%</span></p>
            <p className="text-sm text-gray-600 italic">Note: High risk does not imply price sensitivity.</p>
          </div>

          <div className="bg-blue-50 border-l-4 border-blue-400 p-4">
            <h2 className="font-bold text-blue-800">Causal Inference Analysis</h2>
            <p className="whitespace-pre-wrap text-sm">{result.causal_analysis}</p>
            <p className="mt-2">Average Treatment Effect (ATE): <span className="font-mono font-bold">{result.ate}%</span></p>
          </div>

          <div className={`p-4 rounded-lg ${result.intervention.includes('APPROVE') ? 'bg-green-100 border-green-500' : result.intervention.includes('HOLD') ? 'bg-orange-100 border-orange-500' : 'bg-red-100 border-red-500'} border-l-4`}>
            <h2 className="font-bold">Final Pricing Intervention Strategy</h2>
            <p className="font-semibold mt-1">{result.intervention}</p>
          </div>
        </div>
      )}
    </div>
  );
}

export default App;

Running the Application

  1. Backend: cd backend && pip install -r requirements.txt && uvicorn app.main:app --reload

  2. Ingest: curl -X POST http://localhost:8000/ingest

  3. Frontend: cd frontend && npm install && npm run dev

Analyzing the Causal Output

Try changing the user segment in the UI:

This perfectly illustrates how causal inference prevents revenue leakage caused by relying on spurious correlations.

Enterprise Production Considerations

Limitations

Conclusion

Correlation tells you what is happening; causation tells you what to do about it. In churn prediction and pricing, acting on correlation leads to margin erosion and ineffective interventions. By integrating Causal Inference and Uplift Modeling into a LangGraph multi-agent architecture, supported by RAG over historical experiments, enterprises can build pricing engines that are not just predictive, but truly prescriptive. This ensures that every discount offered is backed by mathematical proof of its impact, safeguarding both retention rates and profitability.