asmitha
Clean public project and improve local data paths
8949395
Raw
History Blame Contribute Delete
9.39 kB
"""
mrr_transform.py - Job 2: MRR / ARR / LTV Calculations
----------------------------------------------------------
Computes Monthly Recurring Revenue, Annual Recurring Revenue,
and Lifetime Value per tenant using Spark SQL window functions.
Run:
python src/transforms/mrr_transform.py
"""
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
try:
from pyspark.sql import functions as F, Window
except ImportError:
F = None
Window = None
from utils.spark_session import get_spark
from utils.filesystem import recreate_dir
from utils.paths import data_path
def compute_mrr(spark):
"""
MRR = sum of all successful invoice_paid amounts per tenant per month.
Uses window functions to compute:
- monthly MRR
- MRR growth % vs prior month
- cumulative MRR (running total)
- 3-month rolling average MRR
"""
events = spark.read.parquet(data_path("processed", "billing_events"))
tenants = spark.read.parquet(data_path("processed", "tenants"))
# Monthly revenue per tenant
monthly_revenue = (
events
.filter(F.col("event_type") == "invoice_paid")
.filter(F.col("status") == "success")
.groupBy("tenant_id", "event_year", "event_month")
.agg(
F.sum("amount").alias("mrr"),
F.count("invoice_id").alias("invoice_count"),
F.avg("seats").alias("avg_seats")
)
.withColumn(
"period",
F.concat(F.col("event_year"), F.lit("-"), F.lpad(F.col("event_month"), 2, "0"))
)
)
# Window: tenant ordered by time
tenant_window = Window.partitionBy("tenant_id").orderBy("event_year", "event_month")
tenant_window_unbounded = (
Window.partitionBy("tenant_id")
.orderBy("event_year", "event_month")
.rowsBetween(Window.unboundedPreceding, Window.currentRow)
)
tenant_window_3m = (
Window.partitionBy("tenant_id")
.orderBy("event_year", "event_month")
.rowsBetween(-2, 0) # 3-month rolling window
)
mrr_enriched = (
monthly_revenue
.withColumn("prev_mrr", F.lag("mrr", 1).over(tenant_window))
.withColumn(
"mrr_growth_pct",
F.round(
F.when(
(F.col("prev_mrr").isNotNull()) & (F.col("prev_mrr") != 0),
(F.col("mrr") - F.col("prev_mrr")) / F.col("prev_mrr") * 100
).otherwise(F.lit(None)),
2,
)
)
.withColumn("cumulative_mrr", F.sum("mrr").over(tenant_window_unbounded))
.withColumn("rolling_3m_mrr", F.avg("mrr").over(tenant_window_3m))
.withColumn("arr", F.col("mrr") * 12)
)
return mrr_enriched, tenants
def compute_ltv(mrr_df, tenants_df):
"""
LTV = Average MRR x average customer lifetime in months.
Simple LTV model: LTV = ARPU / Churn Rate
"""
avg_mrr_per_tenant = (
mrr_df
.groupBy("tenant_id")
.agg(
F.avg("mrr").alias("avg_monthly_mrr"),
F.count("period").alias("active_months"),
F.sum("mrr").alias("total_revenue")
)
)
ltv = (
avg_mrr_per_tenant
.join(tenants_df.select("tenant_id", "company_name", "industry", "plan", "country"), "tenant_id")
.withColumn("estimated_ltv", F.col("avg_monthly_mrr") * F.col("active_months"))
)
return ltv
def run_pandas():
print("\n[JOB 2] MRR / ARR / LTV Computation (Pandas Fallback Engine)\n")
import pandas as pd
import numpy as np
events_path = data_path("processed", "billing_events")
tenants_path = data_path("processed", "tenants")
if not os.path.exists(events_path) or not os.path.exists(tenants_path):
print(" [ERROR] Processed data missing from Job 1. Please run etl_ingest.py first.")
return
# Read Parquets
events = pd.read_parquet(events_path)
events["event_year"] = events["event_year"].astype(int)
events["event_month"] = events["event_month"].astype(int)
tenants = pd.read_parquet(tenants_path)
# Monthly revenue per tenant
invoice_paid = events[(events["event_type"] == "invoice_paid") & (events["status"] == "success")]
monthly_rev = (
invoice_paid
.groupby(["tenant_id", "event_year", "event_month"])
.agg(
mrr=("amount", "sum"),
invoice_count=("invoice_id", "count"),
avg_seats=("seats", "mean")
)
.reset_index()
)
monthly_rev["period"] = (
monthly_rev["event_year"].astype(str) + "-" +
monthly_rev["event_month"].astype(str).str.zfill(2)
)
# Window calculations
# Sort to ensure window functions operate in order
monthly_rev = monthly_rev.sort_values(["tenant_id", "event_year", "event_month"]).reset_index(drop=True)
# Lag
monthly_rev["prev_mrr"] = monthly_rev.groupby("tenant_id")["mrr"].shift(1)
# Growth Pct
monthly_rev["mrr_growth_pct"] = np.where(
(monthly_rev["prev_mrr"].notna()) & (monthly_rev["prev_mrr"] != 0),
((monthly_rev["mrr"] - monthly_rev["prev_mrr"]) / monthly_rev["prev_mrr"] * 100).round(2),
np.nan
)
# Cumulative Sum
monthly_rev["cumulative_mrr"] = monthly_rev.groupby("tenant_id")["mrr"].cumsum()
# 3-Month Rolling Average
# Pandas rolling requires index-based operations. We use groupby and rolling.
monthly_rev["rolling_3m_mrr"] = (
monthly_rev.groupby("tenant_id")["mrr"]
.rolling(window=3, min_periods=1)
.mean()
.reset_index(level=0, drop=True)
)
# ARR
monthly_rev["arr"] = monthly_rev["mrr"] * 12
# LTV
avg_mrr_per_tenant = (
monthly_rev
.groupby("tenant_id")
.agg(
avg_monthly_mrr=("mrr", "mean"),
active_months=("period", "count"),
total_revenue=("mrr", "sum")
)
.reset_index()
)
# Handle tenant join details
tenants_select = tenants[["tenant_id", "company_name", "industry", "plan", "country"]]
ltv = pd.merge(avg_mrr_per_tenant, tenants_select, on="tenant_id", how="inner")
ltv["estimated_ltv"] = ltv["avg_monthly_mrr"] * ltv["active_months"]
# Global MRR Summary
global_mrr = (
monthly_rev
.groupby(["event_year", "event_month", "period"])
.agg(
total_mrr=("mrr", "sum"),
total_arr=("arr", "sum"),
paying_tenants=("tenant_id", "nunique"),
arpu=("mrr", "mean")
)
.reset_index()
.sort_values(["event_year", "event_month"])
)
print(" Global MRR by Month (last 6 periods):")
print(global_mrr.sort_values("period", ascending=False).head(6).to_string(index=False))
print("\n Top 10 Tenants by Estimated LTV:")
print(ltv.sort_values("estimated_ltv", ascending=False).head(10).to_string(index=False))
# Write output parquets
# Clean output directories if they exist
for path in [
data_path("processed", "mrr_by_tenant_month"),
data_path("processed", "global_mrr_monthly"),
data_path("processed", "tenant_ltv"),
]:
recreate_dir(path)
monthly_rev.to_parquet(os.path.join(data_path("processed", "mrr_by_tenant_month"), "part.parquet"), index=False)
global_mrr.to_parquet(os.path.join(data_path("processed", "global_mrr_monthly"), "part.parquet"), index=False)
ltv.to_parquet(os.path.join(data_path("processed", "tenant_ltv"), "part.parquet"), index=False)
print("\n [OK] MRR data written -> data/processed/mrr_by_tenant_month/")
print(" [OK] Global MRR written -> data/processed/global_mrr_monthly/")
print(" [OK] LTV data written -> data/processed/tenant_ltv/")
print("\n[OK] Job 2 complete.\n")
def run():
spark = get_spark("MRR-Transform")
if spark is None:
run_pandas()
return
print("\n[JOB 2] MRR / ARR / LTV Computation\n")
mrr_df, tenants_df = compute_mrr(spark)
# Global MRR summary.
global_mrr = (
mrr_df
.groupBy("event_year", "event_month", "period")
.agg(
F.sum("mrr").alias("total_mrr"),
F.sum("arr").alias("total_arr"),
F.countDistinct("tenant_id").alias("paying_tenants"),
F.avg("mrr").alias("arpu") # Average Revenue Per User
)
.orderBy("event_year", "event_month")
)
print(" Global MRR by Month (last 6 periods):")
global_mrr.orderBy(F.desc("period")).show(6, truncate=False)
# LTV.
ltv_df = compute_ltv(mrr_df, tenants_df)
print(" Top 10 Tenants by Estimated LTV:")
ltv_df.orderBy(F.desc("estimated_ltv")).show(10, truncate=False)
# Write analytical datasets.
mrr_df.write.mode("overwrite").parquet(data_path("processed", "mrr_by_tenant_month"))
global_mrr.write.mode("overwrite").parquet(data_path("processed", "global_mrr_monthly"))
ltv_df.write.mode("overwrite").parquet(data_path("processed", "tenant_ltv"))
print("\n [OK] MRR data written -> data/processed/mrr_by_tenant_month/")
print(" [OK] Global MRR written -> data/processed/global_mrr_monthly/")
print(" [OK] LTV data written -> data/processed/tenant_ltv/")
print("\n[OK] Job 2 complete.\n")
spark.stop()
if __name__ == "__main__":
run()