google-meridian 1.3.1__py3-none-any.whl → 1.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. {google_meridian-1.3.1.dist-info → google_meridian-1.4.0.dist-info}/METADATA +13 -9
  2. google_meridian-1.4.0.dist-info/RECORD +108 -0
  3. {google_meridian-1.3.1.dist-info → google_meridian-1.4.0.dist-info}/top_level.txt +1 -0
  4. meridian/analysis/__init__.py +1 -2
  5. meridian/analysis/analyzer.py +0 -1
  6. meridian/analysis/optimizer.py +5 -3
  7. meridian/analysis/review/checks.py +81 -30
  8. meridian/analysis/review/constants.py +4 -0
  9. meridian/analysis/review/results.py +40 -9
  10. meridian/analysis/summarizer.py +8 -3
  11. meridian/analysis/test_utils.py +934 -485
  12. meridian/analysis/visualizer.py +11 -7
  13. meridian/backend/__init__.py +53 -5
  14. meridian/backend/test_utils.py +72 -0
  15. meridian/constants.py +2 -0
  16. meridian/data/load.py +2 -0
  17. meridian/data/test_utils.py +82 -10
  18. meridian/model/__init__.py +2 -0
  19. meridian/model/context.py +925 -0
  20. meridian/model/eda/__init__.py +0 -1
  21. meridian/model/eda/constants.py +13 -2
  22. meridian/model/eda/eda_engine.py +299 -37
  23. meridian/model/eda/eda_outcome.py +21 -1
  24. meridian/model/equations.py +418 -0
  25. meridian/model/knots.py +75 -47
  26. meridian/model/model.py +93 -792
  27. meridian/{analysis/templates → templates}/card.html.jinja +1 -1
  28. meridian/{analysis/templates → templates}/chart.html.jinja +1 -1
  29. meridian/{analysis/templates → templates}/chips.html.jinja +1 -1
  30. meridian/{analysis → templates}/formatter.py +12 -1
  31. meridian/templates/formatter_test.py +216 -0
  32. meridian/{analysis/templates → templates}/insights.html.jinja +1 -1
  33. meridian/{analysis/templates → templates}/stats.html.jinja +1 -1
  34. meridian/{analysis/templates → templates}/style.css +1 -1
  35. meridian/{analysis/templates → templates}/style.scss +1 -1
  36. meridian/{analysis/templates → templates}/summary.html.jinja +4 -2
  37. meridian/{analysis/templates → templates}/table.html.jinja +1 -1
  38. meridian/version.py +1 -1
  39. scenarioplanner/__init__.py +42 -0
  40. scenarioplanner/converters/__init__.py +25 -0
  41. scenarioplanner/converters/dataframe/__init__.py +28 -0
  42. scenarioplanner/converters/dataframe/budget_opt_converters.py +383 -0
  43. scenarioplanner/converters/dataframe/common.py +71 -0
  44. scenarioplanner/converters/dataframe/constants.py +137 -0
  45. scenarioplanner/converters/dataframe/converter.py +42 -0
  46. scenarioplanner/converters/dataframe/dataframe_model_converter.py +70 -0
  47. scenarioplanner/converters/dataframe/marketing_analyses_converters.py +543 -0
  48. scenarioplanner/converters/dataframe/rf_opt_converters.py +314 -0
  49. scenarioplanner/converters/mmm.py +743 -0
  50. scenarioplanner/converters/mmm_converter.py +58 -0
  51. scenarioplanner/converters/sheets.py +156 -0
  52. scenarioplanner/converters/test_data.py +714 -0
  53. scenarioplanner/linkingapi/__init__.py +47 -0
  54. scenarioplanner/linkingapi/constants.py +27 -0
  55. scenarioplanner/linkingapi/url_generator.py +131 -0
  56. scenarioplanner/mmm_ui_proto_generator.py +354 -0
  57. schema/__init__.py +15 -0
  58. schema/mmm_proto_generator.py +71 -0
  59. schema/model_consumer.py +133 -0
  60. schema/processors/__init__.py +77 -0
  61. schema/processors/budget_optimization_processor.py +832 -0
  62. schema/processors/common.py +64 -0
  63. schema/processors/marketing_processor.py +1136 -0
  64. schema/processors/model_fit_processor.py +367 -0
  65. schema/processors/model_kernel_processor.py +117 -0
  66. schema/processors/model_processor.py +412 -0
  67. schema/processors/reach_frequency_optimization_processor.py +584 -0
  68. schema/test_data.py +380 -0
  69. schema/utils/__init__.py +1 -0
  70. schema/utils/date_range_bucketing.py +117 -0
  71. google_meridian-1.3.1.dist-info/RECORD +0 -76
  72. meridian/model/eda/meridian_eda.py +0 -220
  73. {google_meridian-1.3.1.dist-info → google_meridian-1.4.0.dist-info}/WHEEL +0 -0
  74. {google_meridian-1.3.1.dist-info → google_meridian-1.4.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,70 @@
1
+ # Copyright 2025 The Meridian Authors.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """An output converter that denormalizes into flat data frame tables."""
16
+
17
+ from collections.abc import Mapping, Sequence
18
+
19
+ from scenarioplanner.converters import mmm_converter
20
+ from scenarioplanner.converters.dataframe import budget_opt_converters
21
+ from scenarioplanner.converters.dataframe import converter
22
+ from scenarioplanner.converters.dataframe import marketing_analyses_converters
23
+ from scenarioplanner.converters.dataframe import rf_opt_converters
24
+ import pandas as pd
25
+
26
+
27
+ __all__ = ["DataFrameModelConverter"]
28
+
29
+
30
+ class DataFrameModelConverter(mmm_converter.ModelConverter[pd.DataFrame]):
31
+ """Converts a bound `Mmm` model into denormalized flat data frame tables.
32
+
33
+ The denormalized, two-dimensional data frame tables are intended to be
34
+ directly compiled into sheets in a Google Sheets file to be used as a data
35
+ source for a Looker Studio dashboard.
36
+
37
+ These data frame tables are:
38
+
39
+ * "ModelDiagnostics"
40
+ * "ModelFit"
41
+ * "MediaOutcome"
42
+ * "MediaSpend"
43
+ * "MediaROI"
44
+ * (Named Incremental Outcome Grids)
45
+ * "budget_opt_specs"
46
+ * "budget_opt_results"
47
+ * "response_curves"
48
+ * (Named R&F ROI Grids)
49
+ * "rf_opt_specs"
50
+ * "rf_opt_results"
51
+ """
52
+
53
+ _converters: Sequence[type[converter.Converter]] = (
54
+ marketing_analyses_converters.CONVERTERS
55
+ + budget_opt_converters.CONVERTERS
56
+ + rf_opt_converters.CONVERTERS
57
+ )
58
+
59
+ def __call__(self, **kwargs) -> Mapping[str, pd.DataFrame]:
60
+ """Converts bound `Mmm` model proto to named, flat data frame tables."""
61
+ output = {}
62
+
63
+ for converter_class in self._converters:
64
+ converter_instance = converter_class(self.mmm) # pytype: disable=not-instantiable
65
+ for table_name, table_data in converter_instance():
66
+ if output.get(table_name) is not None:
67
+ raise ValueError(f"Duplicate table name: {table_name}")
68
+ output[table_name] = table_data
69
+
70
+ return output
@@ -0,0 +1,543 @@
1
+ # Copyright 2025 The Meridian Authors.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Marketing analyses converters.
16
+
17
+ This module defines various classes that convert `MarketingAnalysis`s into flat
18
+ dataframes.
19
+ """
20
+
21
+ import abc
22
+ from collections.abc import Iterator, Sequence
23
+ import datetime
24
+ import functools
25
+ import math
26
+ import warnings
27
+
28
+ from meridian import constants as c
29
+ from mmm.v1.fit import model_fit_pb2 as fit_pb
30
+ from scenarioplanner.converters import mmm
31
+ from scenarioplanner.converters.dataframe import constants as dc
32
+ from scenarioplanner.converters.dataframe import converter
33
+ import pandas as pd
34
+
35
+
36
+ __all__ = [
37
+ "ModelDiagnosticsConverter",
38
+ "ModelFitConverter",
39
+ "MediaOutcomeConverter",
40
+ "MediaSpendConverter",
41
+ "MediaRoiConverter",
42
+ ]
43
+
44
+
45
+ class ModelDiagnosticsConverter(converter.Converter):
46
+ """Outputs a "ModelDiagnostics" table.
47
+
48
+ When called, this converter yields a data frame with the columns:
49
+
50
+ * "Dataset"
51
+ * "R Squared"
52
+ * "MAPE"
53
+ * "wMAPE"
54
+ """
55
+
56
+ def __call__(self) -> Iterator[tuple[str, pd.DataFrame]]:
57
+ if not self._mmm.model_fit_results:
58
+ return
59
+
60
+ model_diagnostics_data = []
61
+ for name, result in self._mmm.model_fit_results.items():
62
+ model_diagnostics_data.append((
63
+ name,
64
+ result.performance.r_squared,
65
+ result.performance.mape,
66
+ result.performance.weighted_mape,
67
+ ))
68
+ yield (
69
+ dc.MODEL_DIAGNOSTICS,
70
+ pd.DataFrame(
71
+ model_diagnostics_data,
72
+ columns=[
73
+ dc.MODEL_DIAGNOSTICS_DATASET_COLUMN,
74
+ dc.MODEL_DIAGNOSTICS_R_SQUARED_COLUMN,
75
+ dc.MODEL_DIAGNOSTICS_MAPE_COLUMN,
76
+ dc.MODEL_DIAGNOSTICS_WMAPE_COLUMN,
77
+ ],
78
+ ),
79
+ )
80
+
81
+
82
+ class ModelFitConverter(converter.Converter):
83
+ """Outputs a "ModelFit" table from an "All Data" (*) `Result` dataset.
84
+
85
+ Note: If there is no such result dataset, the first one available is used,
86
+ instead.
87
+
88
+ When called, this converter yields a data frame with the columns:
89
+
90
+ * "Time"
91
+ A string formatted with Meridian date format: YYYY-mm-dd
92
+ * "Expected CI Low"
93
+ * "Expected CI High"
94
+ * "Expected"
95
+ * "Baseline"
96
+ * "Actual"
97
+ """
98
+
99
+ def __call__(self) -> Iterator[tuple[str, pd.DataFrame]]:
100
+ if not self._mmm.model_fit_results:
101
+ return
102
+
103
+ model_fit_data = []
104
+ for prediction in self._select_model_fit_result().predictions:
105
+ time = datetime.datetime(
106
+ year=prediction.date_interval.start_date.year,
107
+ month=prediction.date_interval.start_date.month,
108
+ day=prediction.date_interval.start_date.day,
109
+ ).strftime(c.DATE_FORMAT)
110
+
111
+ if not prediction.predicted_outcome.uncertainties:
112
+ (expected_ci_lo, expected_ci_hi) = (math.nan, math.nan)
113
+ else:
114
+ if len(prediction.predicted_outcome.uncertainties) > 1:
115
+ warnings.warn(
116
+ "More than one `Estimate.uncertainties` found in a"
117
+ " `Prediction.predicted_outcome` in `ModelFit`; processing only"
118
+ " the first confidence interval value."
119
+ )
120
+ uncertainty = prediction.predicted_outcome.uncertainties[0]
121
+ expected_ci_lo = uncertainty.lowerbound
122
+ expected_ci_hi = uncertainty.upperbound
123
+ expected = prediction.predicted_outcome.value
124
+ actual = prediction.actual_value
125
+
126
+ baseline = prediction.predicted_baseline.value
127
+
128
+ model_fit_data.append(
129
+ (time, expected_ci_lo, expected_ci_hi, expected, baseline, actual)
130
+ )
131
+
132
+ yield (
133
+ dc.MODEL_FIT,
134
+ pd.DataFrame(
135
+ model_fit_data,
136
+ columns=[
137
+ dc.MODEL_FIT_TIME_COLUMN,
138
+ dc.MODEL_FIT_EXPECTED_CI_LOW_COLUMN,
139
+ dc.MODEL_FIT_EXPECTED_CI_HIGH_COLUMN,
140
+ dc.MODEL_FIT_EXPECTED_COLUMN,
141
+ dc.MODEL_FIT_BASELINE_COLUMN,
142
+ dc.MODEL_FIT_ACTUAL_COLUMN,
143
+ ],
144
+ ),
145
+ )
146
+
147
+ def _select_model_fit_result(self) -> fit_pb.Result:
148
+ """Returns the model fit `Result` dataset with name "All Data".
149
+
150
+ Or else, first available.
151
+ """
152
+ model_fit_results = self._mmm.model_fit_results
153
+ if not model_fit_results:
154
+ raise ValueError("Must have at least one `ModelFit.results` value.")
155
+ if c.ALL_DATA in model_fit_results:
156
+ result = model_fit_results[c.ALL_DATA]
157
+ else:
158
+ result = self._mmm.model_fit.results[0]
159
+ warnings.warn(f"Using a model fit `Result` with name: '{result.name}'")
160
+
161
+ return result
162
+
163
+
164
+ class _MarketingAnalysisConverter(converter.Converter, abc.ABC):
165
+ """An abstract class for dealing with `MarketingAnalysis`."""
166
+
167
+ @functools.cached_property
168
+ def _is_revenue_kpi(self) -> bool:
169
+ """Returns true if analyses are using revenue KPI.
170
+
171
+ This is done heuristically: by looking at the (presumed existing) "baseline"
172
+ `NonMediaAnalysis` proto and seeing if `revenue_kpi` field is defined. If it
173
+ is, we assume that all other media analyses must have their `revenue_kpi`
174
+ fields defined, too.
175
+
176
+ Likewise: if the baseline analysis defines `non_revenue_kpi` and it does not
177
+ define `revenue_kpi`, we assume that all other media analyses are based on
178
+ a non-revenue KPI.
179
+
180
+ Note: This means that this output converter can only work with one type of
181
+ KPI as a whole. If a channel's media analysis has both revenue- and
182
+ nonrevenue-type KPI defined, for example, only the former will be outputted.
183
+ """
184
+ baseline_analysis = self._mmm.tagged_marketing_analyses[
185
+ dc.ANALYSIS_TAG_ALL
186
+ ].baseline_analysis
187
+ return baseline_analysis.maybe_revenue_outcome is not None
188
+
189
+ def __call__(self) -> Iterator[tuple[str, pd.DataFrame]]:
190
+ if not self._mmm.marketing_analyses:
191
+ return
192
+
193
+ yield from self._handle_marketing_analyses(self._mmm.marketing_analyses)
194
+
195
+ def _handle_marketing_analyses(
196
+ self, analyses: Sequence[mmm.MarketingAnalysis]
197
+ ) -> Iterator[tuple[str, pd.DataFrame]]:
198
+ raise NotImplementedError()
199
+
200
+
201
+ class MediaOutcomeConverter(_MarketingAnalysisConverter):
202
+ """Outputs a "MediaOutcome" table.
203
+
204
+ When called, this converter yields a data frame with the columns:
205
+
206
+ * "Channel Index"
207
+ This is to ensure "baseline" and "All Channels" can be sorted to appear
208
+ first and last, respectively, in LS dashboard.
209
+ * "Channel"
210
+ * "Incremental Outcome"
211
+ * "Contribution Share"
212
+ * "Analysis Period"
213
+ A human-readable analysis period.
214
+ * "Analysis Date Start"
215
+ A string formatted with Meridian date format: YYYY-mm-dd
216
+ * "Analysis Date End"
217
+ A string formatted with Meridian date format: YYYY-mm-dd
218
+
219
+ Note: If the underlying model analysis works with a revenue-type KPI (i.e.
220
+ dollar value), then all values in the columns of the output table should be
221
+ interpreted the same. Likewise, for non-revenue type KPI. While some
222
+ channels may define their outcome analyses in terms of both revenue- and
223
+ nonrevenue-type semantics, the output table here remains uniform.
224
+ """
225
+
226
+ def _handle_marketing_analyses(
227
+ self, analyses: Sequence[mmm.MarketingAnalysis]
228
+ ) -> Iterator[tuple[str, pd.DataFrame]]:
229
+ media_outcome_data = []
230
+ for marketing_analysis in analyses:
231
+ date_start, date_end = marketing_analysis.analysis_date_interval_str
232
+
233
+ baseline_outcome: mmm.Outcome = (
234
+ marketing_analysis.baseline_analysis.revenue_outcome
235
+ if self._is_revenue_kpi
236
+ else marketing_analysis.baseline_analysis.non_revenue_outcome
237
+ )
238
+ # "contribution" == incremental outcome
239
+ baseline_contrib = baseline_outcome.contribution_pb.value.value
240
+ baseline_contrib_share = baseline_outcome.contribution_pb.share.value
241
+
242
+ media_outcome_data.append((
243
+ dc.MEDIA_OUTCOME_BASELINE_PSEUDO_CHANNEL_INDEX,
244
+ c.BASELINE,
245
+ baseline_contrib,
246
+ baseline_contrib_share,
247
+ marketing_analysis.tag,
248
+ date_start,
249
+ date_end,
250
+ ))
251
+ media_analyses = list(
252
+ marketing_analysis.channel_mapped_media_analyses.items()
253
+ )
254
+ non_media_analyses = list(filter(
255
+ lambda x: x[0] != c.BASELINE,
256
+ list(marketing_analysis.channel_mapped_non_media_analyses.items()),
257
+ ))
258
+ all_analyses = media_analyses + non_media_analyses
259
+ for channel, media_analysis in all_analyses:
260
+ channel_index = (
261
+ dc.MEDIA_OUTCOME_ALL_CHANNELS_PSEUDO_CHANNEL_INDEX
262
+ if channel == c.ALL_CHANNELS
263
+ else dc.MEDIA_OUTCOME_CHANNEL_INDEX
264
+ )
265
+ # Note: use the same revenue- or nonrevenue-type outcome analysis as the
266
+ # baseline's.
267
+ try:
268
+ channel_outcome: mmm.Outcome = (
269
+ media_analysis.revenue_outcome
270
+ if self._is_revenue_kpi
271
+ else media_analysis.non_revenue_outcome
272
+ )
273
+ except ValueError:
274
+ warnings.warn(
275
+ f"No {'' if self._is_revenue_kpi else 'non'}revenue-type"
276
+ " `Outcome` found in the channel media analysis for"
277
+ f' "{channel}"'
278
+ )
279
+ channel_contrib = math.nan
280
+ channel_contrib_share = math.nan
281
+ else:
282
+ channel_contrib = channel_outcome.contribution_pb.value.value
283
+ channel_contrib_share = channel_outcome.contribution_pb.share.value
284
+
285
+ media_outcome_data.append((
286
+ channel_index,
287
+ channel,
288
+ channel_contrib,
289
+ channel_contrib_share,
290
+ marketing_analysis.tag,
291
+ date_start,
292
+ date_end,
293
+ ))
294
+
295
+ yield (
296
+ dc.MEDIA_OUTCOME,
297
+ pd.DataFrame(
298
+ media_outcome_data,
299
+ columns=[
300
+ dc.MEDIA_OUTCOME_CHANNEL_INDEX_COLUMN,
301
+ dc.MEDIA_OUTCOME_CHANNEL_COLUMN,
302
+ dc.MEDIA_OUTCOME_INCREMENTAL_OUTCOME_COLUMN,
303
+ dc.MEDIA_OUTCOME_CONTRIBUTION_SHARE_COLUMN,
304
+ dc.ANALYSIS_PERIOD_COLUMN, # using the `tag` field
305
+ dc.ANALYSIS_DATE_START_COLUMN,
306
+ dc.ANALYSIS_DATE_END_COLUMN,
307
+ ],
308
+ ),
309
+ )
310
+
311
+
312
+ class MediaSpendConverter(_MarketingAnalysisConverter):
313
+ """Outputs a "MediaSpend" table.
314
+
315
+ When called, this converter yields a data frame with the columns:
316
+
317
+ * "Channel"
318
+ * "Value"
319
+ * "Label"
320
+ A human-readable label on what "Value" represents
321
+ * "Analysis Period"
322
+ A human-readable analysis period.
323
+ * "Analysis Date Start"
324
+ A string formatted with Meridian date format: YYYY-mm-dd
325
+ * "Analysis Date End"
326
+ A string formatted with Meridian date format: YYYY-mm-dd
327
+
328
+ Note: If the underlying model analysis works with a revenue-type KPI (i.e.
329
+ dollar value), then all values in the columns of the output table should
330
+ be interpreted the same. Likewise, for non-revenue type KPI. While some
331
+ channels may define their outcome analyses in terms of both revenue- and
332
+ nonrevenue-type semantics, the output table here remains uniform.
333
+ """
334
+
335
+ _share_value_column_index = 1
336
+ _label_column_index = 2
337
+
338
+ def _handle_marketing_analyses(
339
+ self, analyses: Sequence[mmm.MarketingAnalysis]
340
+ ) -> Iterator[tuple[str, pd.DataFrame]]:
341
+ media_spend_data = []
342
+
343
+ for analysis in analyses:
344
+ date_start, date_end = analysis.analysis_date_interval_str
345
+ data = []
346
+ outcome_share_norm_term = 0.0
347
+
348
+ for (
349
+ channel,
350
+ media_analysis,
351
+ ) in analysis.channel_mapped_media_analyses.items():
352
+ # Ignore the "All Channels" pseudo-channel.
353
+ if channel == c.ALL_CHANNELS:
354
+ continue
355
+
356
+ spend_share = media_analysis.spend_info_pb.spend_share
357
+
358
+ try:
359
+ channel_outcome: mmm.Outcome = (
360
+ media_analysis.revenue_outcome
361
+ if self._is_revenue_kpi
362
+ else media_analysis.non_revenue_outcome
363
+ )
364
+ except ValueError:
365
+ warnings.warn(
366
+ f"No {'' if self._is_revenue_kpi else 'non'}revenue-type"
367
+ " `Outcome` found in the channel media analysis for"
368
+ f' "{channel}"'
369
+ )
370
+ outcome_share = math.nan
371
+ else:
372
+ outcome_share = channel_outcome.contribution_pb.share.value
373
+ outcome_share_norm_term += outcome_share
374
+
375
+ data.append([
376
+ channel,
377
+ spend_share,
378
+ dc.MEDIA_SPEND_LABEL_SPEND_SHARE,
379
+ analysis.tag,
380
+ date_start,
381
+ date_end,
382
+ ])
383
+ data.append([
384
+ channel,
385
+ outcome_share,
386
+ (
387
+ dc.MEDIA_SPEND_LABEL_REVENUE_SHARE
388
+ if self._is_revenue_kpi
389
+ else dc.MEDIA_SPEND_LABEL_KPI_SHARE
390
+ ),
391
+ analysis.tag,
392
+ date_start,
393
+ date_end,
394
+ ])
395
+
396
+ # Looker Studio media spend/revenue share charts expect the "revenue
397
+ # share" values to be normalized to 100%. This normaliztion provides
398
+ # additional information to what the contribution waterfall chart already
399
+ # provides.
400
+ for d in data:
401
+ if d[self._label_column_index] == dc.MEDIA_SPEND_LABEL_SPEND_SHARE:
402
+ continue
403
+ d[self._share_value_column_index] /= outcome_share_norm_term
404
+
405
+ media_spend_data.extend(data)
406
+
407
+ yield (
408
+ dc.MEDIA_SPEND,
409
+ pd.DataFrame(
410
+ media_spend_data,
411
+ columns=[
412
+ dc.MEDIA_SPEND_CHANNEL_COLUMN,
413
+ dc.MEDIA_SPEND_SHARE_VALUE_COLUMN,
414
+ dc.MEDIA_SPEND_LABEL_COLUMN,
415
+ dc.ANALYSIS_PERIOD_COLUMN, # using the `tag` field
416
+ dc.ANALYSIS_DATE_START_COLUMN,
417
+ dc.ANALYSIS_DATE_END_COLUMN,
418
+ ],
419
+ ),
420
+ )
421
+
422
+
423
+ class MediaRoiConverter(_MarketingAnalysisConverter):
424
+ """Outputs a "MediaROI" table.
425
+
426
+ When called, this converter yields a data frame with the columns:
427
+
428
+ * "Channel"
429
+ * "Spend"
430
+ * "Effectiveness"
431
+ * "ROI"
432
+ * "ROI CI Low"
433
+ The confidence interval (low) of "ROI" above.
434
+ * "ROI CI High"
435
+ The confidence interval (high) of "ROI" above.
436
+ * "Marginal ROI"
437
+ * "Is Revenue KPI"
438
+ A boolean indicating whether "ROI" refers to revenue or generic KPI.
439
+ * "Analysis Period"
440
+ A human-readable analysis period.
441
+ * "Analysis Date Start"
442
+ A string formatted with Meridian date format: YYYY-mm-dd
443
+ * "Analysis Date End"
444
+ A string formatted with Meridian date format: YYYY-mm-dd
445
+
446
+ Note: If the underlying model analysis works with a revenue-type KPI (i.e.
447
+ dollar value), then all values in the columns of the output table should
448
+ be interpreted the same. Likewise, for non-revenue type KPI. While some
449
+ channels may define their outcome analyses in terms of both revenue- and
450
+ nonrevenue-type semantics, the output table here remains uniform.
451
+ """
452
+
453
+ def _handle_marketing_analyses(
454
+ self, analyses: Sequence[mmm.MarketingAnalysis]
455
+ ) -> Iterator[tuple[str, pd.DataFrame]]:
456
+ media_roi_data = []
457
+ for analysis in analyses:
458
+ date_start, date_end = analysis.analysis_date_interval_str
459
+
460
+ for (
461
+ channel,
462
+ media_analysis,
463
+ ) in analysis.channel_mapped_media_analyses.items():
464
+ # Ignore the "All Channels" pseudo-channel.
465
+ if channel == c.ALL_CHANNELS:
466
+ continue
467
+
468
+ spend = media_analysis.spend_info_pb.spend
469
+
470
+ try:
471
+ channel_outcome: mmm.Outcome = (
472
+ media_analysis.revenue_outcome
473
+ if self._is_revenue_kpi
474
+ else media_analysis.non_revenue_outcome
475
+ )
476
+ except ValueError as exc:
477
+ raise ValueError(
478
+ f"No {'' if self._is_revenue_kpi else 'non'}revenue-type"
479
+ " `Outcome` found in the channel media analysis for"
480
+ f' "{channel}"'
481
+ ) from exc
482
+ else:
483
+ effectiveness = channel_outcome.effectiveness_pb.value.value
484
+ roi_estimate = channel_outcome.roi_pb
485
+ if not roi_estimate.uncertainties:
486
+ (roi_ci_lo, roi_ci_hi) = (math.nan, math.nan)
487
+ else:
488
+ if len(roi_estimate.uncertainties) > 1:
489
+ warnings.warn(
490
+ "More than one `Estimate.uncertainties` found in an"
491
+ ' `Outcome.revenue_outcome.roi` in channel "{channel}".'
492
+ " Using the first confidence interval value."
493
+ )
494
+ uncertainty = roi_estimate.uncertainties[0]
495
+ roi_ci_lo = uncertainty.lowerbound
496
+ roi_ci_hi = uncertainty.upperbound
497
+ roi = roi_estimate.value
498
+ marginal_roi = channel_outcome.marginal_roi_pb.value
499
+ is_revenue_kpi = channel_outcome.is_revenue_kpi
500
+
501
+ media_roi_data.append([
502
+ channel,
503
+ spend,
504
+ effectiveness,
505
+ roi,
506
+ roi_ci_lo,
507
+ roi_ci_hi,
508
+ marginal_roi,
509
+ is_revenue_kpi,
510
+ analysis.tag,
511
+ date_start,
512
+ date_end,
513
+ ])
514
+
515
+ yield (
516
+ dc.MEDIA_ROI,
517
+ pd.DataFrame(
518
+ media_roi_data,
519
+ columns=[
520
+ dc.MEDIA_ROI_CHANNEL_COLUMN,
521
+ dc.MEDIA_ROI_SPEND_COLUMN,
522
+ dc.MEDIA_ROI_EFFECTIVENESS_COLUMN,
523
+ dc.MEDIA_ROI_ROI_COLUMN,
524
+ dc.MEDIA_ROI_ROI_CI_LOW_COLUMN,
525
+ dc.MEDIA_ROI_ROI_CI_HIGH_COLUMN,
526
+ dc.MEDIA_ROI_MARGINAL_ROI_COLUMN,
527
+ dc.MEDIA_ROI_IS_REVENUE_KPI_COLUMN,
528
+ dc.ANALYSIS_PERIOD_COLUMN,
529
+ dc.ANALYSIS_DATE_START_COLUMN,
530
+ dc.ANALYSIS_DATE_END_COLUMN,
531
+ ],
532
+ ),
533
+ )
534
+
535
+
536
+ CONVERTERS = [
537
+ # These converters create tables for the model analysis charts to use:
538
+ ModelDiagnosticsConverter,
539
+ ModelFitConverter,
540
+ MediaOutcomeConverter,
541
+ MediaSpendConverter,
542
+ MediaRoiConverter,
543
+ ]