Skip to content

Commit e0d2e84

Browse files
andyl7anThe Meridian Authors
authored and
The Meridian Authors
committed
[Summarizer] Add channel contribution area chart
PiperOrigin-RevId: 739326212
1 parent 1dfc1e5 commit e0d2e84

8 files changed

+496
-111
lines changed

CHANGELOG.md

+6
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,12 @@ To release a new version (e.g. from `1.0.0` -> `2.0.0`):
2323

2424
## [Unreleased]
2525

26+
* Add `plot_channel_contribution_area_chart` method to `MediaSummary` in the
27+
visualizer.
28+
29+
* Update contribution calculation methods in `MediaSummary` with
30+
`aggregate_times` parameter to support granular time.
31+
2632
* Add a `new_data` argument to `analyzer.optimal_freq()`.
2733
* Refactor args in `create_optimization_grid` to be consistent with
2834
`optimize(...)`.

meridian/analysis/summarizer.py

+11-3
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,13 @@ def _create_outcome_contrib_card_html(
270270
) -> str:
271271
"""Creates the HTML snippet for the Outcome Contrib card."""
272272
outcome = self._kpi_or_revenue()
273+
channel_contribution_by_time_chart = formatter.ChartSpec(
274+
id=summary_text.CHANNEL_CONTRIB_BY_TIME_CHART_ID,
275+
description=summary_text.CHANNEL_CONTRIB_BY_TIME_CHART_DESCRIPTION.format(
276+
outcome=outcome
277+
),
278+
chart_json=media_summary.plot_channel_contribution_area_chart().to_json(),
279+
)
273280
channel_drivers_chart = formatter.ChartSpec(
274281
id=summary_text.CHANNEL_DRIVERS_CHART_ID,
275282
description=summary_text.CHANNEL_DRIVERS_CHART_DESCRIPTION.format(
@@ -305,6 +312,7 @@ def _create_outcome_contrib_card_html(
305312
CHANNEL_CONTRIB_CARD_SPEC,
306313
insights,
307314
[
315+
channel_contribution_by_time_chart,
308316
channel_drivers_chart,
309317
spend_outcome_chart,
310318
outcome_contribution_chart,
@@ -318,7 +326,7 @@ def _get_sorted_posterior_mean_metrics_df(
318326
ascending: bool = False,
319327
) -> pd.DataFrame:
320328
return (
321-
media_summary.paid_summary_metrics[metrics]
329+
media_summary.get_paid_summary_metrics()[metrics]
322330
.sel(distribution=c.POSTERIOR, metric=c.MEAN)
323331
.drop_sel(channel=c.ALL_CHANNELS)
324332
.to_dataframe()
@@ -334,7 +342,7 @@ def _get_sorted_posterior_median_metrics_df(
334342
ascending: bool = False,
335343
) -> pd.DataFrame:
336344
return (
337-
media_summary.paid_summary_metrics[metrics]
345+
media_summary.get_paid_summary_metrics()[metrics]
338346
.sel(distribution=c.POSTERIOR, metric=c.MEDIAN)
339347
.drop_sel(channel=c.ALL_CHANNELS)
340348
.to_dataframe()
@@ -479,7 +487,7 @@ def _select_optimal_rf_data(
479487
rf_channels = reach_frequency.optimal_frequency_data.rf_channel
480488
assert rf_channels.size > 0
481489
# This will raise KeyError if not all `rf_channels` can be found in here:
482-
rf_channel_spends = media_summary.paid_summary_metrics[c.SPEND].sel(
490+
rf_channel_spends = media_summary.get_paid_summary_metrics()[c.SPEND].sel(
483491
channel=rf_channels
484492
)
485493
most_spend_rf_channel = rf_channel_spends.idxmax()

meridian/analysis/summarizer_test.py

+27-3
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,9 @@ def _stub_plotters(self):
140140
self.reach_frequency.plot_optimal_frequency().to_json.return_value = '{}'
141141

142142
def _stub_media_summary_plotters(self, media_summary):
143+
media_summary.plot_channel_contribution_area_chart().to_json.return_value = (
144+
'{}'
145+
)
143146
media_summary.plot_contribution_waterfall_chart().to_json.return_value = (
144147
'{}'
145148
)
@@ -152,7 +155,9 @@ def _stub_media_summary_plotters(self, media_summary):
152155

153156
def _stub_for_insights(self):
154157
self.media_metrics = test_utils.generate_paid_summary_metrics()
155-
self.media_summary.paid_summary_metrics = self.media_metrics
158+
self.media_summary.get_paid_summary_metrics = mock.MagicMock(
159+
return_value=self.media_metrics
160+
)
156161

157162
frequency_data = test_utils.generate_optimal_frequency_data(
158163
channel_prefix='rf_ch', num_channels=2
@@ -365,6 +370,12 @@ def test_output_card_static_chart_spec(self, card_spec):
365370
(
366371
summary_text.CHANNEL_CONTRIB_CARD_ID,
367372
[
373+
(
374+
summary_text.CHANNEL_CONTRIB_BY_TIME_CHART_ID,
375+
summary_text.CHANNEL_CONTRIB_BY_TIME_CHART_DESCRIPTION.format(
376+
outcome=c.REVENUE
377+
),
378+
),
368379
(
369380
summary_text.CHANNEL_DRIVERS_CHART_ID,
370381
summary_text.CHANNEL_DRIVERS_CHART_DESCRIPTION.format(
@@ -781,6 +792,10 @@ def test_channel_contrib_card_plotters_called(self):
781792
media_summary.plot_contribution_pie_chart().to_json.return_value = (
782793
f'["{mock_spec_3}"]'
783794
)
795+
mock_spec_4 = 'revenue_area_chart'
796+
media_summary.plot_channel_contribution_area_chart().to_json.return_value = (
797+
f'["{mock_spec_4}"]'
798+
)
784799

785800
summary_html_dom = self._get_output_model_results_summary_html_dom(
786801
self.summarizer_revenue,
@@ -789,6 +804,7 @@ def test_channel_contrib_card_plotters_called(self):
789804
media_summary.plot_contribution_waterfall_chart(),
790805
media_summary.plot_spend_vs_contribution(),
791806
media_summary.plot_contribution_pie_chart(),
807+
media_summary.plot_channel_contribution_area_chart(),
792808
]:
793809
mock_plot.to_json.assert_called_once()
794810

@@ -812,8 +828,16 @@ def test_channel_contrib_card_plotters_called(self):
812828
mock_spec_3_exists = any(
813829
[mock_spec_3 in script_text for script_text in script_texts]
814830
)
831+
mock_spec_4_exists = any(
832+
[mock_spec_4 in script_text for script_text in script_texts]
833+
)
815834
self.assertTrue(
816-
all([mock_spec_1_exists, mock_spec_2_exists, mock_spec_3_exists])
835+
all([
836+
mock_spec_1_exists,
837+
mock_spec_2_exists,
838+
mock_spec_3_exists,
839+
mock_spec_4_exists,
840+
])
817841
)
818842

819843
def test_channel_contrib_card_insights(self):
@@ -998,7 +1022,7 @@ def test_response_curves_card_insights_multiple_channels(self):
9981022
media_summary = self.media_summary
9991023
reach_frequency = self.reach_frequency
10001024

1001-
media_summary.paid_summary_metrics.spend.data = [
1025+
media_summary.get_paid_summary_metrics().spend.data = [
10021026
100, # 'ch_0'
10031027
200, # 'ch_1'
10041028
300, # 'ch_2'

meridian/analysis/summary_text.py

+8
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,14 @@
4242
understand what drove your {outcome}. {lead_channels} drove the most overall
4343
{outcome}."""
4444

45+
CHANNEL_CONTRIB_BY_TIME_CHART_ID = 'channel-contrib-by-time-chart'
46+
CHANNEL_CONTRIB_BY_TIME_CHART_TITLE = (
47+
'Contribution over time by baseline and marketing channels'
48+
)
49+
CHANNEL_CONTRIB_BY_TIME_CHART_DESCRIPTION = """Note: This graphic encompasses all of
50+
your {outcome} drivers, but breaks down your marketing {outcome} by the baseline
51+
and all channels over time."""
52+
4553
CHANNEL_DRIVERS_CHART_ID = 'channel-drivers-chart'
4654
CHANNEL_DRIVERS_CHART_TITLE = 'Contribution by baseline and marketing channels'
4755
CHANNEL_DRIVERS_CHART_DESCRIPTION = """Note: This graphic encompasses all of

meridian/analysis/test_utils.py

+22-19
Original file line numberDiff line numberDiff line change
@@ -2914,7 +2914,7 @@ def generate_paid_summary_metrics() -> xr.Dataset:
29142914
)
29152915

29162916

2917-
def generate_all_summary_metrics() -> xr.Dataset:
2917+
def generate_all_summary_metrics(aggregate_times: bool = True) -> xr.Dataset:
29182918
"""Helper method to generate simulated summary metrics data."""
29192919
channel = (
29202920
[f"ch_{i}" for i in range(3)]
@@ -2926,33 +2926,36 @@ def generate_all_summary_metrics() -> xr.Dataset:
29262926
channel.append(c.ALL_CHANNELS)
29272927
metric = [c.MEAN, c.MEDIAN, c.CI_LO, c.CI_HI]
29282928
distribution = [c.PRIOR, c.POSTERIOR]
2929+
time = [f"time_{i}" for i in range(5)]
29292930

29302931
np.random.seed(0)
2931-
shape = (len(channel), len(metric), len(distribution))
2932+
2933+
if aggregate_times:
2934+
shape = (len(channel), len(metric), len(distribution))
2935+
dims = [c.CHANNEL, c.METRIC, c.DISTRIBUTION]
2936+
else:
2937+
shape = (len(time), len(channel), len(metric), len(distribution))
2938+
dims = [c.TIME, c.CHANNEL, c.METRIC, c.DISTRIBUTION]
2939+
29322940
incremental_outcome = np.random.lognormal(10, 1, size=shape)
29332941
effectiveness = np.random.lognormal(1, 1, size=shape)
29342942
pct_of_contribution = np.random.randint(low=0, high=50, size=shape)
29352943

2944+
coords = {
2945+
c.CHANNEL: channel,
2946+
c.METRIC: metric,
2947+
c.DISTRIBUTION: distribution,
2948+
}
2949+
if not aggregate_times:
2950+
coords[c.TIME] = time
2951+
29362952
return xr.Dataset(
29372953
data_vars={
2938-
c.INCREMENTAL_OUTCOME: (
2939-
[c.CHANNEL, c.METRIC, c.DISTRIBUTION],
2940-
incremental_outcome,
2941-
),
2942-
c.PCT_OF_CONTRIBUTION: (
2943-
[c.CHANNEL, c.METRIC, c.DISTRIBUTION],
2944-
pct_of_contribution,
2945-
),
2946-
c.EFFECTIVENESS: (
2947-
[c.CHANNEL, c.METRIC, c.DISTRIBUTION],
2948-
effectiveness,
2949-
),
2950-
},
2951-
coords={
2952-
c.CHANNEL: channel,
2953-
c.METRIC: metric,
2954-
c.DISTRIBUTION: distribution,
2954+
c.INCREMENTAL_OUTCOME: (dims, incremental_outcome),
2955+
c.PCT_OF_CONTRIBUTION: (dims, pct_of_contribution),
2956+
c.EFFECTIVENESS: (dims, effectiveness),
29552957
},
2958+
coords=coords,
29562959
attrs={c.CONFIDENCE_LEVEL: c.DEFAULT_CONFIDENCE_LEVEL},
29572960
)
29582961

0 commit comments

Comments
 (0)