lib/report.py (451 lines of code) (raw):
import json
import os
import sys
import numpy as np
from scipy import interpolate
from django.template import Template, Context
from django.template.loader import get_template
from airium import Airium
from bs4 import BeautifulSoup as bs
# These values are mostly hand-wavy that seem to
# fit the telemetry result impacts.
def get_cohen_effect_meaning(d):
d_abs = abs(d)
if d_abs <= 0.05:
return "Small"
if d_abs <= 0.1:
return "Medium"
else:
return "Large"
def get_rank_biserial_corr_meaning(r):
r_abs = abs(r)
if r_abs <= 0.05:
return "Small"
if r_abs <= 0.1:
return "Medium"
else:
return "Large"
# CubicSpline requires a monotonically increasing x.
# Remove duplicates.
def cubic_spline_prep(x, y):
new_x = []
new_y = []
for i in range(1, len(x)):
if x[i]-x[i-1] > 0:
new_x.append(x[i])
new_y.append(y[i])
return [new_x, new_y]
def cubic_spline_smooth(x, y, x_new):
[x_prep, y_prep] = cubic_spline_prep(x, y)
tck = interpolate.splrep(x_prep, y_prep, k=3)
y_new = interpolate.splev(x_new, tck, der=0)
return list(y_new)
def find_value_at_quantile(values, cdf, q=0.95):
for i, e in reversed(list(enumerate(cdf))):
if cdf[i] <= q:
if i==len(cdf)-1:
return values[i]
else:
return values[i+1]
def getIconForSegment(segment):
iconMap = {
"All": "fa-solid fa-globe",
"Windows": "fa-brands fa-windows",
"Linux": "fa-brands fa-linux",
"Mac": "fa-brands fa-apple",
"Android": "fa-brands fa-android"
}
if segment in iconMap:
return iconMap[segment]
else:
return "fa-solid fa-chart-simple"
def flip_row_background(color):
if color == "white":
return "#ececec"
else:
return "white"
class ReportGenerator:
def __init__(self, data):
self.data = data
self.doc = Airium()
def createHeader(self):
t = get_template("header.html")
context = {
"title": f"{self.data['slug']} experimental results"
}
self.doc(t.render(context))
def endDocument(self):
self.doc("</body>")
return
def createSidebar(self):
t = get_template("sidebar.html")
segments = []
for segment in self.data['segments']:
entry = { "name": segment,
"icon": getIconForSegment(segment),
"pageload_metrics" : [],
"histograms" : []
}
for metric in self.data['pageload_event_metrics']:
entry["pageload_metrics"].append(metric)
for histogram in self.data['histograms']:
hist_name = histogram.split('.')[-1]
entry["histograms"].append(hist_name)
segments.append(entry)
ctx = {
"segments": segments
}
self.doc(t.render(ctx))
def createSummarySection(self):
t = get_template("summary.html")
control=self.data["branches"][0]
row_background="white";
segments = []
for segment in self.data["segments"]:
numerical_metrics = []
categorical_metrics = []
for metric_type in ["histograms", "pageload_event_metrics"]:
for metric in self.data[metric_type]:
# Alternate between white and #ececec for row backgound.
row_background = flip_row_background(row_background)
if metric_type == "pageload_event_metrics":
kind = "numerical"
metric_name = f"pageload event: {metric}"
else:
kind = self.data[metric_type][metric]["kind"]
metric = metric.split(".")[-1]
metric_name = metric
# Generate summary for categorical histograms here.
if kind == "categorical":
branches = []
for branch in self.data["branches"]:
if "uplift" in self.data[branch][segment][metric_type][metric]:
rows = []
n_labels = len(self.data[branch][segment][metric_type][metric]["labels"])
for i in range(n_labels):
label = self.data[branch][segment][metric_type][metric]["labels"][i]
uplift = self.data[branch][segment][metric_type][metric]["uplift"][i]
# Enumerated histograms have a lot of labels, so try and limit the ones
# we show.
if n_labels > 5 and abs(uplift)<0.05:
continue
weight="font-weight:normal;"
if abs(uplift) >= 10:
effect = "Large"
weight = "font-weight:bold;"
elif abs(uplift) >= 5:
effect = "Medium"
weight = "font-weight:bold;"
elif abs(uplift) >= 2:
effect = "Small"
else:
effect = "None"
if uplift > 0:
uplift = "+{0:.2f}".format(self.data[branch][segment][metric_type][metric]["uplift"][i])
else:
uplift = "{0:.2f}".format(self.data[branch][segment][metric_type][metric]["uplift"][i])
uplift_desc=f"{label:<15}: {uplift}%"
rows.append({
"uplift": uplift_desc,
"effect": effect,
"weight": weight,
"style": f"background:{row_background};",
})
rows[-1]["style"] = rows[-1]["style"] + "border-bottom-style: solid;"
branches.append({
"branch": branch,
"style": f"background:{row_background};",
"branch_rowspan": len(rows),
"rows": rows
})
branches[-1]["style"] = branches[-1]["style"] + "border-bottom-style: solid;"
total_rowspan = 0
for i in range(len(branches)):
total_rowspan = total_rowspan + branches[i]["branch_rowspan"]
categorical_metrics.append({
"name": metric_name,
"desc": self.data[branch][segment][metric_type][metric]["desc"],
"style": f"background:{row_background}; border-bottom-style: solid; border-right-style: solid;",
"name_rowspan": total_rowspan,
"branches": branches
})
continue
# Generate summary for numerical histograms here.
datasets = []
for branch in self.data["branches"]:
if branch == control:
continue
mean = "{0:.1f}".format(self.data[branch][segment][metric_type][metric]["mean"])
std = "{0:.1f}".format(self.data[branch][segment][metric_type][metric]["std"])
branch_mean = self.data[branch][segment][metric_type][metric]["mean"]
control_mean = self.data[control][segment][metric_type][metric]["mean"]
uplift = (branch_mean-control_mean)/control_mean*100.0
if uplift > 0:
uplift_str = "+{0:.1f}".format(uplift)
else:
uplift_str = "{0:.1f}".format(uplift)
pval = self.data[branch][segment][metric_type][metric]["tests"]["mwu"]["p-value"]
effect_size = self.data[branch][segment][metric_type][metric]["tests"]["mwu"]["effect"]
effect_meaning = get_rank_biserial_corr_meaning(effect_size)
effect_size = "{0:.2f}".format(effect_size)
effect = f"{effect_meaning} (r={effect_size})"
if pval >= 0.001:
pval = "{0:.2f}".format(pval)
effect = f"None (p={pval})"
effect_meaning = "None"
if effect_meaning == "None" or effect_meaning == "Small":
color="font-weight: normal"
else:
if uplift >= 1.5:
color="font-weight: bold; color: red"
elif uplift <= -1.5:
color="font-weight: bold; color: green"
else:
color="font-weight: normal"
dataset = {
"branch": branch,
"mean": mean,
"uplift": uplift_str,
"std": std,
"effect": effect,
"color": color,
"style": f"background:{row_background};"
}
datasets.append(dataset);
datasets[-1]["style"] = datasets[-1]["style"] + "border-bottom-style:solid;"
numerical_metrics.append({ "desc": metric_name,
"name": metric,
"desc": self.data[branch][segment][metric_type][metric]["desc"],
"style": f"background:{row_background}; border-bottom-style:solid; border-right-style:solid;",
"datasets": datasets,
"rowspan": len(datasets)})
segments.append({
"name": segment,
"numerical_metrics": numerical_metrics,
"categorical_metrics": categorical_metrics
})
slug = self.data['slug']
is_experiment = self.data['is_experiment']
if is_experiment:
startDate = self.data['startDate']
endDate = self.data['endDate']
channel = self.data['channel']
else:
startDate = None,
endDate = None
channel = None
branches=[]
for i in range(len(self.data['input']['branches'])):
if is_experiment:
branchInfo = {
"name": self.data['input']['branches'][i]['name']
}
else:
branchInfo = {
"name": self.data['input']['branches'][i]['name'],
"startDate": self.data['input']['branches'][i]['startDate'],
"endDate": self.data['input']['branches'][i]['endDate'],
"channel": self.data['input']['branches'][i]['channel']
}
branches.append(branchInfo)
context = {
"slug": slug,
"is_experiment": is_experiment,
"startDate": startDate,
"endDate": endDate,
"channel": channel,
"branches": branches,
"segments": segments,
"branchlen": len(branches)
}
self.doc(t.render(context))
def createConfigSection(self):
t = get_template("config.html")
context = {
"config": json.dumps(self.data["input"], indent=4),
"queries": self.data['queries']
}
self.doc(t.render(context))
def createCDFComparison(self, segment, metric, metric_type):
t = get_template("cdf.html")
control = self.data["branches"][0]
values_control = self.data[control][segment][metric_type][metric]["pdf"]["values"]
cdf_control = self.data[control][segment][metric_type][metric]["pdf"]["cdf"]
maxValue = find_value_at_quantile(values_control, cdf_control)
values_int = list(np.around(np.linspace(0, maxValue, 100), 2))
datasets = []
for branch in self.data["branches"]:
values = self.data[branch][segment][metric_type][metric]["pdf"]["values"]
density = self.data[branch][segment][metric_type][metric]["pdf"]["density"]
cdf = self.data[branch][segment][metric_type][metric]["pdf"]["cdf"]
# Smooth out pdf and cdf, and use common X values for each branch.
density_int = cubic_spline_smooth(values, density, values_int)
cdf_int = cubic_spline_smooth(values, cdf, values_int)
dataset = {
"branch": branch,
"cdf": cdf_int,
"density": density_int,
}
datasets.append(dataset)
context = {
"segment": segment,
"metric": metric,
"values": values_int,
"datasets": datasets
}
self.doc(t.render(context))
return
def calculate_uplift_interp(self, quantiles, branch, segment, metric_type, metric):
control = self.data["branches"][0]
quantiles_control = self.data[control][segment][metric_type][metric]["quantiles"]
values_control = self.data[control][segment][metric_type][metric]["quantile_vals"]
[quantiles_control_n, values_control_n] = cubic_spline_prep(quantiles_control, values_control)
tck = interpolate.splrep(quantiles_control_n, values_control_n, k=1)
values_control_n = interpolate.splev(quantiles, tck, der=0)
quantiles_branch = self.data[branch][segment][metric_type][metric]["quantiles"]
values_branch = self.data[branch][segment][metric_type][metric]["quantile_vals"]
[quantiles_branch_n, values_branch_n] = cubic_spline_prep(quantiles_branch, values_branch)
tck = interpolate.splrep(quantiles_branch_n, values_branch_n, k=1)
values_branch_n = interpolate.splev(quantiles, tck, der=0)
uplifts = []
diffs = []
for i in range(len(quantiles)):
diff = values_branch_n[i] - values_control_n[i]
uplift = diff/values_control_n[i]*100
diffs.append(diff)
uplifts.append(uplift)
return [diffs, uplifts]
def createUpliftComparison(self, segment, metric, metric_type):
t = get_template("uplift.html")
control = self.data["branches"][0]
quantiles = list(np.around(np.linspace(0.1, 0.99, 99), 2))
datasets = []
for branch in self.data["branches"]:
if branch == control:
continue
[diff, uplift] = self.calculate_uplift_interp(quantiles, branch, segment, metric_type, metric)
dataset = {
"branch": branch,
"diff": diff,
"uplift": uplift,
}
datasets.append(dataset)
maxVal = 0
for x in diff:
if abs(x) > maxVal:
maxVal = abs(x)
maxPerc = 0
for x in uplift:
if abs(x) > maxPerc:
maxPerc = abs(x)
context = {
"segment": segment,
"metric": metric,
"quantiles": quantiles,
"datasets": datasets,
"upliftMax": maxPerc,
"upliftMin": -maxPerc,
"diffMax": maxVal,
"diffMin": -maxVal
}
self.doc(t.render(context))
def createMeanComparison(self, segment, metric, metric_type):
t = get_template("mean.html")
datasets = []
control=self.data["branches"][0]
for branch in self.data["branches"]:
n = int(self.data[branch][segment][metric_type][metric]["n"])
n = f'{n:,}'
mean = "{0:.1f}".format(self.data[branch][segment][metric_type][metric]["mean"])
if branch != control:
branch_mean = self.data[branch][segment][metric_type][metric]["mean"]
control_mean = self.data[control][segment][metric_type][metric]["mean"]
uplift = (branch_mean-control_mean)/control_mean*100.0
uplift = "{0:.1f}".format(uplift)
else:
uplift = ""
se = "{0:.1f}".format(self.data[branch][segment][metric_type][metric]["se"])
std = "{0:.1f}".format(self.data[branch][segment][metric_type][metric]["std"])
dataset = {
"branch": branch,
"mean": mean,
"uplift": uplift,
"n": n,
"se": se,
"std": std,
"control": branch==control
}
if branch != control:
for test in self.data[branch][segment][metric_type][metric]["tests"]:
effect = "{0:.2f}".format(self.data[branch][segment][metric_type][metric]["tests"][test]["effect"])
pval = "{0:.2g}".format(self.data[branch][segment][metric_type][metric]["tests"][test]["p-value"])
dataset[test] = {
"effect": effect,
"pval": pval
}
datasets.append(dataset)
context = {
"segment": segment,
"metric": metric,
"branches": self.data["branches"],
"datasets": datasets
}
self.doc(t.render(context))
def createCategoricalComparison(self, segment, metric, metric_type):
t = get_template("categorical.html")
# If the histogram has too many buckets, then only display a
# set of interesting comparisons instead of all of them.
indices = set()
control = self.data["branches"][0]
n_elem = len(self.data[control][segment][metric_type][metric]["ratios"])
if n_elem <= 10:
indices = set(range(0, n_elem-1))
for branch in self.data["branches"]:
if branch == control:
continue
uplift = self.data[branch][segment][metric_type][metric]["uplift"]
ratios = self.data[branch][segment][metric_type][metric]["ratios"]
ratios_control = self.data[control][segment][metric_type][metric]["ratios"]
for i in range(len(uplift)):
if abs(uplift[i]) > 0.01 and (ratios[i] >= 0.05 or ratios_control[i] >= 0.1):
indices.add(i)
datasets=[]
for branch in self.data["branches"]:
ratios_branch = [self.data[branch][segment][metric_type][metric]["ratios"][i] for i in indices]
datasets.append({
"branch": branch,
"ratios": ratios_branch,
})
if branch != control:
ratios_control = [self.data[control][segment][metric_type][metric]["ratios"][i] for i in indices]
uplift = [self.data[branch][segment][metric_type][metric]["uplift"][i] for i in indices]
datasets[-1]["uplift"] = uplift
labels=[self.data[control][segment][metric_type][metric]["labels"][i] for i in indices]
context = {
"labels": labels,
"datasets": datasets,
"metric": metric,
"segment": segment
}
self.doc(t.render(context))
def createMetrics(self, segment, metric, metric_type, kind):
# Perform a separate comparison when data is categorical.
if kind=="categorical":
self.createCategoricalComparison(segment, metric, metric_type)
return
# Add mean comparison
self.createMeanComparison(segment, metric, metric_type)
# Add PDF and CDF comparison
self.createCDFComparison(segment, metric, metric_type)
# Add uplift comparison
self.createUpliftComparison(segment, metric, metric_type)
def createPageloadEventMetrics(self, segment):
for metric in self.data['pageload_event_metrics']:
with self.doc.div(id=f"{segment}-{metric}", klass="cell"):
# Add title for metric
with self.doc.div(klass="title"):
self.doc(f"({segment}) - {metric}")
self.createMetrics(segment, metric, "pageload_event_metrics", "numerical")
def createHistogramMetrics(self, segment):
for hist in self.data['histograms']:
kind = self.data["histograms"][hist]["kind"]
metric = hist.split('.')[-1]
with self.doc.div(id=f"{segment}-{metric}", klass="cell"):
# Add title for metric
with self.doc.div(klass="title"):
self.doc(f"({segment}) - {metric}")
self.createMetrics(segment, metric, "histograms", kind)
return
def createHTMLReport(self):
self.createHeader()
self.createSidebar()
# Create a summary of results
self.createSummarySection()
# Generate charts and tables for each segment and metric
for segment in self.data['segments']:
self.createHistogramMetrics(segment)
self.createPageloadEventMetrics(segment)
# Dump the config and queries used for the report
self.createConfigSection()
self.endDocument()
# Prettify the output
soup = bs(str(self.doc), 'html.parser')
return soup.prettify()