Skip to content

Commit 05215d5

Browse files
nmahadevan-googThe Meridian Authors
authored andcommitted
Allow media_effects_dist to be specified per channel in ModelSpec.
PiperOrigin-RevId: 938025299
1 parent 06768b6 commit 05215d5

11 files changed

Lines changed: 455 additions & 149 deletions

File tree

meridian/model/context.py

Lines changed: 180 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
"""Defines ModelContext class for Meridian."""
1616
from __future__ import annotations
1717

18-
from collections.abc import Mapping, Sequence
18+
from collections.abc import Callable, Mapping, Sequence
1919
import dataclasses
2020
import functools
2121
import warnings
@@ -76,6 +76,7 @@ def __init__(
7676

7777
self._validate_data_dependent_model_spec()
7878
self._validate_model_spec_shapes()
79+
self._validate_media_effects_dist_keys()
7980

8081
self._set_total_media_contribution_prior = False
8182
self._warn_setting_ignored_priors()
@@ -610,12 +611,121 @@ def kpi_scaled(self) -> backend.Tensor:
610611
return self.kpi_transformer.forward(self.kpi)
611612

612613
@functools.cached_property
613-
def media_effects_dist(self) -> str:
614+
def media_effects_dist(self) -> str | Mapping[str, str]:
614615
if self.is_national:
615616
return constants.NATIONAL_MODEL_SPEC_ARGS[constants.MEDIA_EFFECTS_DIST] # pytype: disable=bad-return-type
616617
else:
617618
return self._model_spec.media_effects_dist
618619

620+
def _validate_media_effects_dist_keys(self):
621+
spec_dist = self._model_spec.media_effects_dist
622+
if isinstance(spec_dist, Mapping):
623+
valid_channels = set(
624+
self._input_data.get_all_adstock_hill_channels().tolist()
625+
)
626+
for channel in spec_dist:
627+
if channel not in valid_channels:
628+
raise ValueError(
629+
f"Unrecognized channel name '{channel}' in `media_effects_dist` keys."
630+
f" Keys should contain only channel names from {sorted(valid_channels)}."
631+
)
632+
633+
def _get_media_effects_dist_mask(
634+
self,
635+
channels: np.ndarray | None,
636+
builder_fn: Callable,
637+
target_dist: str,
638+
) -> backend.Tensor:
639+
if channels is None:
640+
return backend.to_tensor([], dtype=backend.bool_)
641+
642+
if self.is_national:
643+
is_target = target_dist == constants.MEDIA_EFFECTS_NORMAL
644+
return backend.broadcast_to(
645+
backend.to_tensor(is_target, dtype=backend.bool_),
646+
[len(channels)],
647+
)
648+
649+
spec_dist = self._model_spec.media_effects_dist
650+
if isinstance(spec_dist, str):
651+
is_target = spec_dist == target_dist
652+
return backend.broadcast_to(
653+
backend.to_tensor(is_target, dtype=backend.bool_),
654+
[len(channels)],
655+
)
656+
657+
# Otherwise it is a mapping.
658+
builder = builder_fn().with_default_value(
659+
constants.MEDIA_EFFECTS_LOG_NORMAL
660+
)
661+
dists = builder(**spec_dist)
662+
is_target_list = [d == target_dist for d in dists]
663+
return backend.to_tensor(is_target_list, dtype=backend.bool_)
664+
665+
@functools.cached_property
666+
def media_effects_dist_normal_m(self) -> backend.Tensor:
667+
return self._get_media_effects_dist_mask(
668+
channels=self._input_data.media_channel,
669+
builder_fn=self._input_data.get_paid_media_channels_argument_builder,
670+
target_dist=constants.MEDIA_EFFECTS_NORMAL,
671+
)
672+
673+
@functools.cached_property
674+
def media_effects_dist_normal_rf(self) -> backend.Tensor:
675+
return self._get_media_effects_dist_mask(
676+
channels=self._input_data.rf_channel,
677+
builder_fn=self._input_data.get_paid_rf_channels_argument_builder,
678+
target_dist=constants.MEDIA_EFFECTS_NORMAL,
679+
)
680+
681+
@functools.cached_property
682+
def media_effects_dist_normal_om(self) -> backend.Tensor:
683+
return self._get_media_effects_dist_mask(
684+
channels=self._input_data.organic_media_channel,
685+
builder_fn=self._input_data.get_organic_media_channels_argument_builder,
686+
target_dist=constants.MEDIA_EFFECTS_NORMAL,
687+
)
688+
689+
@functools.cached_property
690+
def media_effects_dist_normal_orf(self) -> backend.Tensor:
691+
return self._get_media_effects_dist_mask(
692+
channels=self._input_data.organic_rf_channel,
693+
builder_fn=self._input_data.get_organic_rf_channels_argument_builder,
694+
target_dist=constants.MEDIA_EFFECTS_NORMAL,
695+
)
696+
697+
@functools.cached_property
698+
def media_effects_dist_log_normal_m(self) -> backend.Tensor:
699+
return self._get_media_effects_dist_mask(
700+
channels=self._input_data.media_channel,
701+
builder_fn=self._input_data.get_paid_media_channels_argument_builder,
702+
target_dist=constants.MEDIA_EFFECTS_LOG_NORMAL,
703+
)
704+
705+
@functools.cached_property
706+
def media_effects_dist_log_normal_rf(self) -> backend.Tensor:
707+
return self._get_media_effects_dist_mask(
708+
channels=self._input_data.rf_channel,
709+
builder_fn=self._input_data.get_paid_rf_channels_argument_builder,
710+
target_dist=constants.MEDIA_EFFECTS_LOG_NORMAL,
711+
)
712+
713+
@functools.cached_property
714+
def media_effects_dist_log_normal_om(self) -> backend.Tensor:
715+
return self._get_media_effects_dist_mask(
716+
channels=self._input_data.organic_media_channel,
717+
builder_fn=self._input_data.get_organic_media_channels_argument_builder,
718+
target_dist=constants.MEDIA_EFFECTS_LOG_NORMAL,
719+
)
720+
721+
@functools.cached_property
722+
def media_effects_dist_log_normal_orf(self) -> backend.Tensor:
723+
return self._get_media_effects_dist_mask(
724+
channels=self._input_data.organic_rf_channel,
725+
builder_fn=self._input_data.get_organic_rf_channels_argument_builder,
726+
target_dist=constants.MEDIA_EFFECTS_LOG_NORMAL,
727+
)
728+
619729
@functools.cached_property
620730
def unique_sigma_for_each_geo(self) -> bool:
621731
if self.is_national:
@@ -786,86 +896,109 @@ def _check_media_prior_support(self) -> None:
786896
Priors for ROI, mROI, and Contribution can only have negative support if the
787897
random effects follow a normal distribution. This check enforces that priors
788898
have non-negative support when random effects follow a log-normal
789-
distribution. This check only applies to geo-level models with log-normal
790-
random effects since national models do not have random effects.
899+
distribution for that channel. This check only applies to geo-level models
900+
with log-normal random effects since national models do not have random
901+
effects.
791902
"""
792903
prior = self._model_spec.prior
793904
if self.n_media_channels > 0:
794905
self._check_for_negative_support(
795-
prior.roi_m,
796-
self.media_effects_dist,
797-
constants.TREATMENT_PRIOR_TYPE_ROI,
906+
dist=prior.roi_m,
907+
log_normal_mask=self.media_effects_dist_log_normal_m,
908+
should_check=(
909+
self._model_spec.effective_media_prior_type
910+
== constants.TREATMENT_PRIOR_TYPE_ROI
911+
),
798912
)
799913
self._check_for_negative_support(
800-
prior.mroi_m,
801-
self.media_effects_dist,
802-
constants.TREATMENT_PRIOR_TYPE_MROI,
914+
dist=prior.mroi_m,
915+
log_normal_mask=self.media_effects_dist_log_normal_m,
916+
should_check=(
917+
self._model_spec.effective_media_prior_type
918+
== constants.TREATMENT_PRIOR_TYPE_MROI
919+
),
803920
)
804921
self._check_for_negative_support(
805-
prior.contribution_m,
806-
self.media_effects_dist,
807-
constants.TREATMENT_PRIOR_TYPE_CONTRIBUTION,
922+
dist=prior.contribution_m,
923+
log_normal_mask=self.media_effects_dist_log_normal_m,
924+
should_check=(
925+
self._model_spec.effective_media_prior_type
926+
== constants.TREATMENT_PRIOR_TYPE_CONTRIBUTION
927+
),
808928
)
809929
if self.n_rf_channels > 0:
810930
self._check_for_negative_support(
811-
prior.roi_rf,
812-
self.media_effects_dist,
813-
constants.TREATMENT_PRIOR_TYPE_ROI,
931+
dist=prior.roi_rf,
932+
log_normal_mask=self.media_effects_dist_log_normal_rf,
933+
should_check=(
934+
self._model_spec.effective_rf_prior_type
935+
== constants.TREATMENT_PRIOR_TYPE_ROI
936+
),
814937
)
815938
self._check_for_negative_support(
816-
prior.mroi_rf,
817-
self.media_effects_dist,
818-
constants.TREATMENT_PRIOR_TYPE_MROI,
939+
dist=prior.mroi_rf,
940+
log_normal_mask=self.media_effects_dist_log_normal_rf,
941+
should_check=(
942+
self._model_spec.effective_rf_prior_type
943+
== constants.TREATMENT_PRIOR_TYPE_MROI
944+
),
819945
)
820946
self._check_for_negative_support(
821-
prior.contribution_rf,
822-
self.media_effects_dist,
823-
constants.TREATMENT_PRIOR_TYPE_CONTRIBUTION,
947+
dist=prior.contribution_rf,
948+
log_normal_mask=self.media_effects_dist_log_normal_rf,
949+
should_check=(
950+
self._model_spec.effective_rf_prior_type
951+
== constants.TREATMENT_PRIOR_TYPE_CONTRIBUTION
952+
),
824953
)
825954
if self.n_organic_media_channels > 0:
826955
self._check_for_negative_support(
827-
prior.contribution_om,
828-
self.media_effects_dist,
829-
constants.TREATMENT_PRIOR_TYPE_CONTRIBUTION,
956+
dist=prior.contribution_om,
957+
log_normal_mask=self.media_effects_dist_log_normal_om,
958+
should_check=(
959+
self._model_spec.organic_media_prior_type
960+
== constants.TREATMENT_PRIOR_TYPE_CONTRIBUTION
961+
),
830962
)
831963
if self.n_organic_rf_channels > 0:
832964
self._check_for_negative_support(
833-
prior.contribution_orf,
834-
self.media_effects_dist,
835-
constants.TREATMENT_PRIOR_TYPE_CONTRIBUTION,
965+
dist=prior.contribution_orf,
966+
log_normal_mask=self.media_effects_dist_log_normal_orf,
967+
should_check=(
968+
self._model_spec.organic_rf_prior_type
969+
== constants.TREATMENT_PRIOR_TYPE_CONTRIBUTION
970+
),
836971
)
837972

838973
def _check_for_negative_support(
839974
self,
840975
dist: backend.tfd.Distribution,
841-
media_effects_dist: str,
842-
prior_type: str,
976+
log_normal_mask: backend.Tensor,
977+
should_check: bool,
843978
) -> None:
844979
"""Checks for negative support in prior distributions.
845980
846-
When `media_effects_dist` is `MEDIA_EFFECTS_LOG_NORMAL`, prior distributions
847-
for media effects must be non-negative. This function raises a ValueError if
848-
any part of the distribution's CDF is greater than 0 at 0, indicating some
849-
probability mass below zero.
981+
When `media_effects_dist` is `MEDIA_EFFECTS_LOG_NORMAL` for a channel, prior
982+
distributions for media effects must be non-negative. This function raises a
983+
ValueError if any part of the distribution's CDF is greater than 0 at 0 for
984+
log-normal channels, indicating some probability mass below zero.
850985
851986
Args:
852987
dist: The distribution to check.
853-
media_effects_dist: The type of media effects distribution.
854-
prior_type: The prior type that corresponds with current prior under test.
855-
856-
Raises:
857-
ValueError: If the prior distribution has negative support when
858-
`media_effects_dist` is `MEDIA_EFFECTS_LOG_NORMAL`.
988+
log_normal_mask: A boolean tensor indicating which channels are
989+
log-normal.
990+
should_check: Whether to perform the check.
859991
"""
860-
if (
861-
prior_type == self._model_spec.media_prior_type
862-
and media_effects_dist == constants.MEDIA_EFFECTS_LOG_NORMAL
863-
and np.any(dist.cdf(0) > 0)
864-
):
992+
if not should_check:
993+
return
994+
995+
cdf_zero = dist.cdf(0)
996+
cond = (cdf_zero > 0) & log_normal_mask
997+
if bool(backend.reduce_any(cond)):
865998
raise ValueError(
866999
"Media priors must have non-negative support when"
867-
f' `media_effects_dist`="{media_effects_dist}". Found negative prior'
868-
f" distribution support for {dist.name}."
1000+
" `media_effects_dist` is log_normal for that channel. Found negative"
1001+
f" prior distribution support for {dist.name}."
8691002
)
8701003

8711004
@functools.cached_property

0 commit comments

Comments
 (0)