diff --git a/meridian/analysis/analyzer.py b/meridian/analysis/analyzer.py index 943652ed7..d8e7e18c3 100644 --- a/meridian/analysis/analyzer.py +++ b/meridian/analysis/analyzer.py @@ -1632,6 +1632,7 @@ def _validate_geo_and_time_granularity( self, selected_geos: Sequence[str] | None = None, selected_times: Sequence[str] | Sequence[bool] | None = None, + media_selected_times: Sequence[str] | Sequence[bool] | None = None, aggregate_geos: bool = True, ): """Validates the geo and time granularity arguments for ROI analysis. @@ -1642,6 +1643,9 @@ def _validate_geo_and_time_granularity( selected_times: Optional. Contains a subset of times to include or booleans with length `input_data.n_times`. By default, all time periods are included. + media_selected_times: Optional. Contains a subset of media times to + include or booleans with length `input_data.n_media_times`. By default, + all time periods are included. aggregate_geos: If `True`, then expected revenue is summed over all regions. @@ -1672,13 +1676,22 @@ def _validate_geo_and_time_granularity( " Meridian `rf_spend` data does not have a geo dimension." ) - if selected_times is not None: + if selected_times is not None or media_selected_times is not None: + arg_str = ( + "`selected_times`" + if media_selected_times is None + else ( + "`media_selected_times`" + if selected_times is None + else "`selected_times` and `media_selected_times`" + ) + ) if ( self.model_context.media_tensors.media_spend is not None and not self.model_context.input_data.media_spend_has_time_dimension ): raise ValueError( - "`selected_times` is not allowed because Meridian `media_spend`" + f"{arg_str} is not allowed because Meridian `media_spend`" " data does not have a time dimension." ) if ( @@ -1686,10 +1699,36 @@ def _validate_geo_and_time_granularity( and not self.model_context.input_data.rf_spend_has_time_dimension ): raise ValueError( - "`selected_times` is not allowed because Meridian `rf_spend` data" + f"{arg_str} is not allowed because Meridian `rf_spend` data" " does not have a time dimension." ) + def _get_media_times_for_unscaled_inputs( + self, + selected_times: Sequence[str] | Sequence[bool] | None = None, + media_selected_times: Sequence[str] | Sequence[bool] | None = None, + ) -> Sequence[str] | Sequence[bool] | None: + """Returns spend times for unscaled inputs. + + Determines the spend time window (e.g. for the ROI/mROI denominator). + `media_selected_times` takes precedence when provided (since that specifies + the media execution of interest). If `media_selected_times` is None, + `selected_times` is used, or all time periods if both are None. + """ + spend_times = ( + media_selected_times + if media_selected_times is not None + else selected_times + ) + if ( + spend_times is not None + and tensors._is_bool_list(spend_times) + and len(spend_times) == self.model_context.n_media_times + and self.model_context.n_media_times > self.model_context.n_times + ): + return spend_times[-self.model_context.n_times :] + return spend_times + def marginal_roi( self, incremental_increase: float = 0.01, @@ -1697,6 +1736,7 @@ def marginal_roi( new_data: DataTensors | None = None, selected_geos: Sequence[str] | None = None, selected_times: Sequence[str] | Sequence[bool] | None = None, + media_selected_times: Sequence[str] | Sequence[bool] | None = None, aggregate_geos: bool = True, by_reach: bool = True, use_kpi: bool = False, @@ -1745,7 +1785,15 @@ def marginal_roi( all geos are included. selected_times: Optional list containing either a subset of dates to include or booleans with length equal to the number of time periods in - the `new_data` args, if provided. By default, all time periods are + the `new_data` args, if provided. The mROI corresponds to the + incremental outcome generated during `selected_times` by media executed + during `media_selected_times`. By default, all time periods are + included. + media_selected_times: Optional list containing either a subset of dates to + include or booleans with length equal to the number of time periods in + the `new_data` args, if provided. The mROI corresponds to the + incremental outcome generated during `selected_times` by media executed + during `media_selected_times`. By default, all time periods are included. aggregate_geos: If `True`, the expected revenue is summed over all of the regions. @@ -1767,6 +1815,7 @@ def marginal_roi( dim_kwargs = { "selected_geos": selected_geos, "selected_times": selected_times, + "media_selected_times": media_selected_times, "aggregate_geos": aggregate_geos, } use_kpi = self._use_kpi(use_kpi) @@ -1796,10 +1845,14 @@ def marginal_roi( ) spend_inc = filled_data.total_spend() * incremental_increase # pyrefly: ignore[unsupported-operation] if spend_inc is not None and spend_inc.ndim == 3: # pyrefly: ignore[missing-attribute] + spend_times = self._get_media_times_for_unscaled_inputs( + selected_times=selected_times, + media_selected_times=media_selected_times, + ) inputs = builder.build_unscaled_inputs( new_data=filled_data, selected_geos=selected_geos, - selected_times=selected_times, + selected_times=spend_times, ) return backend.divide( numerator, # pyrefly: ignore[bad-argument-type] @@ -1830,6 +1883,7 @@ def roi( new_data: DataTensors | None = None, selected_geos: Sequence[str] | None = None, selected_times: Sequence[str] | Sequence[bool] | None = None, + media_selected_times: Sequence[str] | Sequence[bool] | None = None, aggregate_geos: bool = True, use_kpi: bool = False, batch_size: int = constants.DEFAULT_BATCH_SIZE, @@ -1873,8 +1927,14 @@ def roi( all geos are included. selected_times: Optional list containing either a subset of dates to include or booleans with length equal to the number of time periods in - the `new_data` args, if provided. By default, all time periods are - included. + the `new_data` args, if provided. The ROI corresponds to the incremental + outcome generated during `selected_times` by media executed during + `media_selected_times`. By default, all time periods are included. + media_selected_times: Optional list containing either a subset of dates to + include or booleans with length equal to the number of time periods in + the `new_data` args, if provided. The ROI corresponds to the incremental + outcome generated during `selected_times` by media executed during + `media_selected_times`. By default, all time periods are included. aggregate_geos: Boolean. If `True`, the expected revenue is summed over all of the regions. use_kpi: If `False`, then revenue is used to calculate the ROI numerator. @@ -1893,6 +1953,7 @@ def roi( dim_kwargs = { "selected_geos": selected_geos, "selected_times": selected_times, + "media_selected_times": media_selected_times, "aggregate_geos": aggregate_geos, } incremental_outcome_kwargs = { @@ -1922,10 +1983,14 @@ def roi( spend = filled_data.total_spend() if spend is not None and spend.ndim == 3: + spend_times = self._get_media_times_for_unscaled_inputs( + selected_times=selected_times, + media_selected_times=media_selected_times, + ) inputs = builder.build_unscaled_inputs( new_data=filled_data, selected_geos=selected_geos, - selected_times=selected_times, + selected_times=spend_times, ) return backend.divide( incremental_outcome, # pyrefly: ignore[bad-argument-type] @@ -1957,6 +2022,7 @@ def cpik( new_data: DataTensors | None = None, selected_geos: Sequence[str] | None = None, selected_times: Sequence[str] | Sequence[bool] | None = None, + media_selected_times: Sequence[str] | Sequence[bool] | None = None, aggregate_geos: bool = True, batch_size: int = constants.DEFAULT_BATCH_SIZE, ) -> backend.Tensor: @@ -2001,7 +2067,15 @@ def cpik( all geos are included. selected_times: Optional list containing either a subset of dates to include or booleans with length equal to the number of time periods in - the `new_data` args, if provided. By default, all time periods are + the `new_data` args, if provided. The CPIK corresponds to the + incremental KPI generated during `selected_times` by media executed + during `media_selected_times`. By default, all time periods are + included. + media_selected_times: Optional list containing either a subset of dates to + include or booleans with length equal to the number of time periods in + the `new_data` args, if provided. The CPIK corresponds to the + incremental KPI generated during `selected_times` by media executed + during `media_selected_times`. By default, all time periods are included. aggregate_geos: Boolean. If `True`, the expected KPI is summed over all of the regions. @@ -2021,6 +2095,7 @@ def cpik( new_data=new_data, selected_geos=selected_geos, selected_times=selected_times, + media_selected_times=media_selected_times, aggregate_geos=aggregate_geos, batch_size=batch_size, ) @@ -2298,8 +2373,8 @@ def compute_incremental_outcome_aggregate( `model_spec.non_media_baseline_values` is used, which defaults to the minimum value for each non_media treatment channel. **kwargs: kwargs to pass to `incremental_outcome`, which could contain - selected_geos, selected_times, aggregate_geos, aggregate_times, - batch_size. + selected_geos, selected_times, media_selected_times, aggregate_geos, + aggregate_times, batch_size. Returns: A Tensor with the same dimensions as `incremental_outcome` except the size @@ -2331,6 +2406,7 @@ def summary_metrics( marginal_roi_incremental_increase: float = 0.01, selected_geos: Sequence[str] | None = None, selected_times: Sequence[str] | Sequence[bool] | None = None, + media_selected_times: Sequence[str] | Sequence[bool] | None = None, aggregate_geos: bool = True, aggregate_times: bool = True, optimal_frequency: Sequence[float] | None = None, @@ -2388,8 +2464,16 @@ def summary_metrics( default, all geos are included. selected_times: Optional list containing either a subset of dates to include or booleans with length equal to the number of time periods in - the tensors in the `new_data` argument, if provided. By default, all - time periods are included. + the tensors in the `new_data` argument, if provided. Summary metrics + correspond to outcomes evaluated over `selected_times` generated by + media executed during `media_selected_times`. By default, all time + periods are included. + media_selected_times: Optional list containing either a subset of dates to + include or booleans with length equal to the number of time periods in + the tensors in the `new_data` argument, if provided. Summary metrics + correspond to outcomes evaluated over `selected_times` generated by + media executed during `media_selected_times`. By default, all time + periods are included. aggregate_geos: Boolean. If `True`, the expected outcome is summed over all of the regions. aggregate_times: Boolean. If `True`, the expected outcome is summed over @@ -2441,6 +2525,10 @@ def summary_metrics( "aggregate_geos": aggregate_geos, "aggregate_times": aggregate_times, } + incremental_kwargs = { + **dim_kwargs, + "media_selected_times": media_selected_times, + } batched_kwargs = {"batch_size": batch_size} new_data = new_data or DataTensors() builder = tensors.DataTensorsBuilder(self.model_context) @@ -2469,7 +2557,7 @@ def summary_metrics( use_kpi=use_kpi, include_non_paid_channels=include_non_paid_channels, non_media_baseline_values=non_media_baseline_values, - **dim_kwargs, + **incremental_kwargs, **batched_kwargs, ) incremental_outcome_posterior = self.compute_incremental_outcome_aggregate( @@ -2478,7 +2566,7 @@ def summary_metrics( use_kpi=use_kpi, include_non_paid_channels=include_non_paid_channels, non_media_baseline_values=non_media_baseline_values, - **dim_kwargs, + **incremental_kwargs, **batched_kwargs, ) incremental_outcome_mroi_prior = self.compute_incremental_outcome_aggregate( @@ -2490,7 +2578,7 @@ def summary_metrics( scaling_factor1=1 + marginal_roi_incremental_increase, include_non_paid_channels=include_non_paid_channels, non_media_baseline_values=non_media_baseline_values, - **dim_kwargs, + **incremental_kwargs, **batched_kwargs, ) incremental_outcome_mroi_posterior = ( @@ -2503,7 +2591,7 @@ def summary_metrics( scaling_factor1=1 + marginal_roi_incremental_increase, include_non_paid_channels=include_non_paid_channels, non_media_baseline_values=non_media_baseline_values, - **dim_kwargs, + **incremental_kwargs, **batched_kwargs, ) ) @@ -2647,10 +2735,14 @@ def summary_metrics( if self.model_context.n_rf_channels > 0: spend_list.append(new_spend_tensors.rf_spend) # TODO Add support for 1-dimensional spend. + spend_times = self._get_media_times_for_unscaled_inputs( + selected_times=selected_times, + media_selected_times=media_selected_times, + ) spend_inputs = builder.build_unscaled_inputs( new_data=new_data, selected_geos=selected_geos, - selected_times=selected_times, + selected_times=spend_times, ) aggregated_spend = self.filter_and_aggregate_by_indices( tensor=backend.concatenate(spend_list, axis=-1), @@ -2709,8 +2801,8 @@ def summary_metrics( # aggregated "All Paid Channels" channel dimension value. # "Marginal ROI" calculation must arbitrarily assume how the # "next dollar" of spend is allocated across "All Paid Channels" in - # this case, which may cause confusion in Meridian model and does - # not have much practical usefulness, anyway. + # this case, which may cause confusion in Meridian model and does not + # have much practical usefulness, anyway. ).where(lambda ds: ds.channel != constants.ALL_CHANNELS) cpik = self._compute_cpik_aggregate( incremental_kpi_prior=self.compute_incremental_outcome_aggregate( @@ -2718,7 +2810,7 @@ def summary_metrics( new_data=new_data.filter_fields(incremental_outcome_fields), use_kpi=True, include_non_paid_channels=False, - **dim_kwargs, + **incremental_kwargs, **batched_kwargs, ), incremental_kpi_posterior=self.compute_incremental_outcome_aggregate( @@ -2726,7 +2818,7 @@ def summary_metrics( new_data=new_data.filter_fields(incremental_outcome_fields), use_kpi=True, include_non_paid_channels=False, - **dim_kwargs, + **incremental_kwargs, **batched_kwargs, ), spend_with_total=spend_with_total, @@ -2993,6 +3085,7 @@ def optimal_freq( use_kpi: bool = False, selected_geos: Sequence[str] | None = None, selected_times: Sequence[str] | Sequence[bool] | None = None, + media_selected_times: Sequence[str] | Sequence[bool] | None = None, confidence_level: float = constants.DEFAULT_CONFIDENCE_LEVEL, ) -> xr.Dataset: """Calculates the optimal frequency that maximizes posterior mean ROI. @@ -3041,7 +3134,15 @@ def optimal_freq( selected_times: Optional list containing either a subset of dates to include or booleans with length equal to the number of time periods in `new_data` if time is modified in `new_data`, or `input_data.n_times` - otherwise. By default, all time periods are included. + otherwise. Evaluates outcomes over `selected_times` for media executed + during `media_selected_times`. By default, all time periods are + included. + media_selected_times: Optional list containing either a subset of dates to + include or booleans with length equal to the number of time periods in + `new_data` if time is modified in `new_data`, or `input_data.n_times` + otherwise. Evaluates outcomes over `selected_times` for media executed + during `media_selected_times`. By default, all time periods are + included. confidence_level: Confidence level for prior and posterior credible intervals, represented as a value between zero and one. @@ -3111,9 +3212,9 @@ def optimal_freq( inputs = builder.build_unscaled_inputs( new_data=new_data, required_tensors_names=[ - constants.RF_IMPRESSIONS, - constants.RF_SPEND, - constants.REVENUE_PER_KPI, + constants.RF_IMPRESSIONS, + constants.RF_SPEND, + constants.REVENUE_PER_KPI, ], optimal_frequency=freq, insert_dummy_media=True, @@ -3123,6 +3224,7 @@ def optimal_freq( use_posterior=use_posterior, selected_geos=selected_geos, selected_times=selected_times, + media_selected_times=media_selected_times, aggregate_geos=True, use_kpi=use_kpi, )[..., -self.model_context.n_rf_channels :] @@ -3156,6 +3258,7 @@ def optimal_freq( marginal_roi_by_reach=True, selected_geos=selected_geos, selected_times=selected_times, + media_selected_times=media_selected_times, use_kpi=use_kpi, ).sel({ constants.CHANNEL: rf_channel_values, @@ -3166,6 +3269,7 @@ def optimal_freq( marginal_roi_by_reach=False, selected_geos=selected_geos, selected_times=selected_times, + media_selected_times=media_selected_times, use_kpi=use_kpi, ).sel({ constants.CHANNEL: rf_channel_values, @@ -3597,7 +3701,8 @@ def response_curves( spend_multipliers: list[float] | None = None, use_posterior: bool = True, selected_geos: Sequence[str] | None = None, - selected_times: Sequence[str] | None = None, + selected_times: Sequence[str] | Sequence[bool] | None = None, + media_selected_times: Sequence[str] | Sequence[bool] | None = None, by_reach: bool = True, use_optimal_frequency: bool = False, use_kpi: bool = False, @@ -3635,11 +3740,20 @@ def response_curves( generated. If `False`, prior response curves are generated. selected_geos: Optional list containing a subset of geos to include. By default, all geos are included. - selected_times: Optional list containing a subset of dates to include. If - `new_data` is provided with modified time periods, then `selected_times` - must be a subset of `new_data.times`. Otherwise, `selected_times` must - be a subset of `self._model_context.input_data.time`. By default, all - time periods are included. + selected_times: Optional list containing a subset of dates to include or + booleans with length equal to the number of time periods in the + `new_data` args, if provided. If `new_data` is provided with modified + time periods, then `selected_times` must be a subset of + `new_data.times`. Otherwise, `selected_times` must be a subset of + `self._model_context.input_data.time`. By default, all time periods are + included. + media_selected_times: Optional list containing a subset of dates to + include or booleans with length equal to the number of time periods in + the `new_data` args, if provided. If `new_data` is provided with + modified time periods, then `media_selected_times` can select any subset + of time periods in `new_data`. If `new_data` is not provided, + `media_selected_times` selects from `InputData.time`. Defaults to + include all time periods. by_reach: Boolean. For channels with reach and frequency. If `True`, plots the response curve by reach. If `False`, plots the response curve by frequency. @@ -3665,6 +3779,7 @@ def response_curves( dim_kwargs = { "selected_geos": selected_geos, "selected_times": selected_times, + "media_selected_times": media_selected_times, "aggregate_geos": True, "aggregate_times": True, } @@ -3677,7 +3792,6 @@ def response_curves( new_data=new_data, required_tensors_names=required_tensors_names, ).tensors - dim_kwargs["selected_times"] = selected_times if self.model_context.n_rf_channels > 0 and use_optimal_frequency: opt_freq_data = DataTensors( @@ -3693,6 +3807,7 @@ def response_curves( new_data=opt_freq_data, selected_geos=selected_geos, selected_times=selected_times, + media_selected_times=media_selected_times, use_kpi=use_kpi, ).optimal_frequency, dtype=backend.float_dtype, @@ -3745,10 +3860,14 @@ def response_curves( spend = filled_data.total_spend() if spend is not None and spend.ndim == 3: + spend_times = self._get_media_times_for_unscaled_inputs( + selected_times=selected_times, + media_selected_times=media_selected_times, + ) spend_inputs = builder.build_unscaled_inputs( new_data=filled_data, selected_geos=selected_geos, - selected_times=selected_times, + selected_times=spend_times, ) spend = self.filter_and_aggregate_by_indices( tensor=spend, diff --git a/meridian/analysis/analyzer_test.py b/meridian/analysis/analyzer_test.py index 36ba31d04..7c9a57e26 100644 --- a/meridian/analysis/analyzer_test.py +++ b/meridian/analysis/analyzer_test.py @@ -3701,6 +3701,19 @@ def test_optimal_freq_new_times_data_correct(self): ) xr.testing.assert_allclose(actual, expected) + def test_optimal_freq_media_selected_times(self): + media_times = list(self.input_data.media_time.values) + freq_grid = [1.0, 2.0, 3.0] + opt_dates = self.analyzer.optimal_freq( + media_selected_times=media_times[-10:], + freq_grid=freq_grid, + ) + opt_bools = self.analyzer.optimal_freq( + media_selected_times=[False] * (_N_MEDIA_TIMES - 10) + [True] * 10, + freq_grid=freq_grid, + ) + xr.testing.assert_allclose(opt_dates, opt_bools) + @parameterized.product( use_posterior=[False, True], selected_geos=[None, ["geo_1", "geo_3"]], @@ -3897,6 +3910,16 @@ def test_marginal_roi_new_times_data_correct(self): ) backend_test_utils.assert_allclose(actual, expected, rtol=1e-3, atol=1e-3) + def test_marginal_roi_media_selected_times(self): + media_times = list(self.input_data.media_time.values) + mroi_dates = self.analyzer.marginal_roi( + media_selected_times=media_times[-10:], + ) + mroi_bools = self.analyzer.marginal_roi( + media_selected_times=[False] * (_N_MEDIA_TIMES - 10) + [True] * 10, + ) + backend_test_utils.assert_allclose(mroi_dates, mroi_bools) + @parameterized.product( use_posterior=[False, True], aggregate_geos=[False, True], @@ -4010,6 +4033,152 @@ def test_roi_zero_media_spend_returns_inf(self): np.testing.assert_array_equal(np.isinf(roi), np.full(roi.shape, True)) # pyrefly: ignore[no-matching-overload] + def test_roi_media_selected_times(self): + media_times = list(self.input_data.media_time.values) + roi_dates = self.analyzer.roi( + media_selected_times=media_times[-10:], + ) + roi_bools = self.analyzer.roi( + media_selected_times=[False] * (_N_MEDIA_TIMES - 10) + [True] * 10, + ) + backend_test_utils.assert_allclose(roi_dates, roi_bools) + + def test_get_media_times_for_unscaled_inputs(self): + media_times = list(self.input_data.media_time.values) + times = list(self.input_data.time.values) + n_times = self.analyzer.model_context.n_times + n_media_times = self.analyzer.model_context.n_media_times + + # None cases + self.assertIsNone( + self.analyzer._get_media_times_for_unscaled_inputs(None, None) + ) + + # Boolean list with n_media_times elements + bools_media = [False] * (n_media_times - 5) + [True] * 5 + res = self.analyzer._get_media_times_for_unscaled_inputs( + media_selected_times=bools_media + ) + self.assertEqual(res, [False] * (n_times - 5) + [True] * 5) + + # Boolean list with n_times elements + bools_times = [False] * (n_times - 3) + [True] * 3 + res = self.analyzer._get_media_times_for_unscaled_inputs( + media_selected_times=bools_times + ) + self.assertEqual(res, bools_times) + + # String list + res = self.analyzer._get_media_times_for_unscaled_inputs( + media_selected_times=media_times + ) + self.assertEqual(res, media_times) + + # selected_times fallback when media_selected_times is None + res = self.analyzer._get_media_times_for_unscaled_inputs( + selected_times=times[:5] + ) + self.assertEqual(res, times[:5]) + + @parameterized.named_parameters( + dict( + testcase_name="selected_times_only", + selected_times=["2021-04-19"], + media_selected_times=None, + expected_arg_str="`selected_times`", + ), + dict( + testcase_name="media_selected_times_only", + selected_times=None, + media_selected_times=["2021-04-19"], + expected_arg_str="`media_selected_times`", + ), + dict( + testcase_name="both_times", + selected_times=["2021-04-19"], + media_selected_times=["2021-04-19"], + expected_arg_str="`selected_times` and `media_selected_times`", + ), + ) + def test_validate_geo_and_time_granularity_media_spend_no_time_dim( + self, + selected_times: Sequence[str] | None, + media_selected_times: Sequence[str] | None, + expected_arg_str: str, + ): + with mock.patch.object( + type(self.analyzer.model_context.input_data), + "media_spend_has_time_dimension", + new=mock.PropertyMock(return_value=False), + ): + expected_message = ( + rf"^{expected_arg_str} is not allowed because Meridian `media_spend`" + r" data does not have a time dimension\.$" + ) + with self.assertRaisesRegex(ValueError, expected_message): + self.analyzer._validate_geo_and_time_granularity( + selected_times=selected_times, + media_selected_times=media_selected_times, + ) + + @parameterized.named_parameters( + dict( + testcase_name="selected_times_only", + selected_times=["2021-04-19"], + media_selected_times=None, + expected_arg_str="`selected_times`", + ), + dict( + testcase_name="media_selected_times_only", + selected_times=None, + media_selected_times=["2021-04-19"], + expected_arg_str="`media_selected_times`", + ), + dict( + testcase_name="both_times", + selected_times=["2021-04-19"], + media_selected_times=["2021-04-19"], + expected_arg_str="`selected_times` and `media_selected_times`", + ), + ) + def test_validate_geo_and_time_granularity_rf_spend_no_time_dim( + self, + selected_times: Sequence[str] | None, + media_selected_times: Sequence[str] | None, + expected_arg_str: str, + ): + with mock.patch.object( + type(self.analyzer.model_context.input_data), + "media_spend_has_time_dimension", + new=mock.PropertyMock(return_value=True), + ), mock.patch.object( + type(self.analyzer.model_context.input_data), + "rf_spend_has_time_dimension", + new=mock.PropertyMock(return_value=False), + ): + expected_message = ( + rf"^{expected_arg_str} is not allowed because Meridian `rf_spend`" + r" data does not have a time dimension\.$" + ) + with self.assertRaisesRegex(ValueError, expected_message): + self.analyzer._validate_geo_and_time_granularity( + selected_times=selected_times, + media_selected_times=media_selected_times, + ) + + def test_roi_raises_when_media_spend_has_no_time_dim(self): + with mock.patch.object( + type(self.analyzer.model_context.input_data), + "media_spend_has_time_dimension", + new=mock.PropertyMock(return_value=False), + ): + with self.assertRaisesRegex( + ValueError, + r"^`media_selected_times` is not allowed because Meridian" + r" `media_spend` data does not have a time dimension\.$", + ): + self.analyzer.roi(media_selected_times=["2021-04-19"]) + @parameterized.product( use_posterior=[False, True], aggregate_geos=[False, True], @@ -4069,6 +4238,16 @@ def test_cpik_new_times_data_correct(self): ) backend_test_utils.assert_allclose(actual, expected) + def test_cpik_media_selected_times(self): + media_times = list(self.input_data.media_time.values) + cpik_dates = self.analyzer.cpik( + media_selected_times=media_times[-10:], + ) + cpik_bools = self.analyzer.cpik( + media_selected_times=[False] * (_N_MEDIA_TIMES - 10) + [True] * 10, + ) + backend_test_utils.assert_allclose(cpik_dates, cpik_bools) + def test_media_summary_warns_if_time_not_aggregated(self): with self.assertWarnsRegex( UserWarning, @@ -4638,6 +4817,16 @@ def test_summary_metrics_custom_times_preserved(self): ] self.assertEqual(actual_times, ["0", "1", "2", "3", "4"]) + def test_summary_metrics_media_selected_times(self): + media_times = list(self.input_data.media_time.values) + metrics_dates = self.analyzer.summary_metrics( + media_selected_times=media_times[-10:], + ) + metrics_bools = self.analyzer.summary_metrics( + media_selected_times=[False] * (_N_MEDIA_TIMES - 10) + [True] * 10, + ) + xr.testing.assert_allclose(metrics_dates, metrics_bools) + @parameterized.product( aggregate_geos=[False, True], aggregate_times=[False, True], @@ -5365,6 +5554,18 @@ def test_response_curves_new_times_data_correct(self): expected = self.analyzer.response_curves(selected_times=selected_times_str) xr.testing.assert_allclose(actual, expected, rtol=1e-3, atol=1e-3) + def test_response_curves_media_selected_times(self): + media_times = list(self.input_data.media_time.values) + rc_dates = self.analyzer.response_curves( + media_selected_times=media_times[-10:], + spend_multipliers=[0.0, 1.0, 2.0], + ) + rc_bools = self.analyzer.response_curves( + media_selected_times=[False] * (_N_MEDIA_TIMES - 10) + [True] * 10, + spend_multipliers=[0.0, 1.0, 2.0], + ) + xr.testing.assert_allclose(rc_dates, rc_bools) + @parameterized.named_parameters( dict(testcase_name="historical_frequency", use_optimal_frequency=False), dict(testcase_name="optimal_frequency", use_optimal_frequency=True),