google-meridian 1.3.2__py3-none-any.whl → 1.5.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.
- {google_meridian-1.3.2.dist-info → google_meridian-1.5.0.dist-info}/METADATA +18 -11
- google_meridian-1.5.0.dist-info/RECORD +112 -0
- {google_meridian-1.3.2.dist-info → google_meridian-1.5.0.dist-info}/WHEEL +1 -1
- {google_meridian-1.3.2.dist-info → google_meridian-1.5.0.dist-info}/top_level.txt +1 -0
- meridian/analysis/analyzer.py +558 -398
- meridian/analysis/optimizer.py +90 -68
- meridian/analysis/review/reviewer.py +4 -1
- meridian/analysis/summarizer.py +13 -3
- meridian/analysis/test_utils.py +2911 -2102
- meridian/analysis/visualizer.py +37 -14
- meridian/backend/__init__.py +106 -0
- meridian/constants.py +2 -0
- meridian/data/input_data.py +30 -52
- meridian/data/input_data_builder.py +2 -9
- meridian/data/test_utils.py +107 -51
- meridian/data/validator.py +48 -0
- meridian/mlflow/autolog.py +19 -9
- meridian/model/__init__.py +2 -0
- meridian/model/adstock_hill.py +3 -5
- meridian/model/context.py +1059 -0
- meridian/model/eda/constants.py +335 -4
- meridian/model/eda/eda_engine.py +723 -312
- meridian/model/eda/eda_outcome.py +177 -33
- meridian/model/equations.py +418 -0
- meridian/model/knots.py +58 -47
- meridian/model/model.py +228 -878
- meridian/model/model_test_data.py +38 -0
- meridian/model/posterior_sampler.py +103 -62
- meridian/model/prior_sampler.py +114 -94
- meridian/model/spec.py +23 -14
- meridian/templates/card.html.jinja +9 -7
- meridian/templates/chart.html.jinja +1 -6
- meridian/templates/finding.html.jinja +19 -0
- meridian/templates/findings.html.jinja +33 -0
- meridian/templates/formatter.py +41 -5
- meridian/templates/formatter_test.py +127 -0
- meridian/templates/style.css +66 -9
- meridian/templates/style.scss +85 -4
- meridian/templates/table.html.jinja +1 -0
- meridian/version.py +1 -1
- scenarioplanner/__init__.py +42 -0
- scenarioplanner/converters/__init__.py +25 -0
- scenarioplanner/converters/dataframe/__init__.py +28 -0
- scenarioplanner/converters/dataframe/budget_opt_converters.py +383 -0
- scenarioplanner/converters/dataframe/common.py +71 -0
- scenarioplanner/converters/dataframe/constants.py +137 -0
- scenarioplanner/converters/dataframe/converter.py +42 -0
- scenarioplanner/converters/dataframe/dataframe_model_converter.py +70 -0
- scenarioplanner/converters/dataframe/marketing_analyses_converters.py +543 -0
- scenarioplanner/converters/dataframe/rf_opt_converters.py +314 -0
- scenarioplanner/converters/mmm.py +743 -0
- scenarioplanner/converters/mmm_converter.py +58 -0
- scenarioplanner/converters/sheets.py +156 -0
- scenarioplanner/converters/test_data.py +714 -0
- scenarioplanner/linkingapi/__init__.py +47 -0
- scenarioplanner/linkingapi/constants.py +27 -0
- scenarioplanner/linkingapi/url_generator.py +131 -0
- scenarioplanner/mmm_ui_proto_generator.py +355 -0
- schema/__init__.py +5 -2
- schema/mmm_proto_generator.py +71 -0
- schema/model_consumer.py +133 -0
- schema/processors/__init__.py +77 -0
- schema/processors/budget_optimization_processor.py +832 -0
- schema/processors/common.py +64 -0
- schema/processors/marketing_processor.py +1137 -0
- schema/processors/model_fit_processor.py +367 -0
- schema/processors/model_kernel_processor.py +117 -0
- schema/processors/model_processor.py +415 -0
- schema/processors/reach_frequency_optimization_processor.py +584 -0
- schema/serde/distribution.py +12 -7
- schema/serde/hyperparameters.py +54 -107
- schema/serde/meridian_serde.py +6 -1
- schema/test_data.py +380 -0
- schema/utils/__init__.py +2 -0
- schema/utils/date_range_bucketing.py +117 -0
- schema/utils/proto_enum_converter.py +127 -0
- google_meridian-1.3.2.dist-info/RECORD +0 -76
- {google_meridian-1.3.2.dist-info → google_meridian-1.5.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
|
+
]
|