|
15 | 15 | """Defines ModelContext class for Meridian.""" |
16 | 16 | from __future__ import annotations |
17 | 17 |
|
18 | | -from collections.abc import Mapping, Sequence |
| 18 | +from collections.abc import Callable, Mapping, Sequence |
19 | 19 | import dataclasses |
20 | 20 | import functools |
21 | 21 | import warnings |
@@ -76,6 +76,7 @@ def __init__( |
76 | 76 |
|
77 | 77 | self._validate_data_dependent_model_spec() |
78 | 78 | self._validate_model_spec_shapes() |
| 79 | + self._validate_media_effects_dist_keys() |
79 | 80 |
|
80 | 81 | self._set_total_media_contribution_prior = False |
81 | 82 | self._warn_setting_ignored_priors() |
@@ -610,12 +611,121 @@ def kpi_scaled(self) -> backend.Tensor: |
610 | 611 | return self.kpi_transformer.forward(self.kpi) |
611 | 612 |
|
612 | 613 | @functools.cached_property |
613 | | - def media_effects_dist(self) -> str: |
| 614 | + def media_effects_dist(self) -> str | Mapping[str, str]: |
614 | 615 | if self.is_national: |
615 | 616 | return constants.NATIONAL_MODEL_SPEC_ARGS[constants.MEDIA_EFFECTS_DIST] # pytype: disable=bad-return-type |
616 | 617 | else: |
617 | 618 | return self._model_spec.media_effects_dist |
618 | 619 |
|
| 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 | + |
619 | 729 | @functools.cached_property |
620 | 730 | def unique_sigma_for_each_geo(self) -> bool: |
621 | 731 | if self.is_national: |
@@ -786,86 +896,109 @@ def _check_media_prior_support(self) -> None: |
786 | 896 | Priors for ROI, mROI, and Contribution can only have negative support if the |
787 | 897 | random effects follow a normal distribution. This check enforces that priors |
788 | 898 | 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. |
791 | 902 | """ |
792 | 903 | prior = self._model_spec.prior |
793 | 904 | if self.n_media_channels > 0: |
794 | 905 | 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 | + ), |
798 | 912 | ) |
799 | 913 | 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 | + ), |
803 | 920 | ) |
804 | 921 | 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 | + ), |
808 | 928 | ) |
809 | 929 | if self.n_rf_channels > 0: |
810 | 930 | 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 | + ), |
814 | 937 | ) |
815 | 938 | 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 | + ), |
819 | 945 | ) |
820 | 946 | 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 | + ), |
824 | 953 | ) |
825 | 954 | if self.n_organic_media_channels > 0: |
826 | 955 | 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 | + ), |
830 | 962 | ) |
831 | 963 | if self.n_organic_rf_channels > 0: |
832 | 964 | 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 | + ), |
836 | 971 | ) |
837 | 972 |
|
838 | 973 | def _check_for_negative_support( |
839 | 974 | self, |
840 | 975 | dist: backend.tfd.Distribution, |
841 | | - media_effects_dist: str, |
842 | | - prior_type: str, |
| 976 | + log_normal_mask: backend.Tensor, |
| 977 | + should_check: bool, |
843 | 978 | ) -> None: |
844 | 979 | """Checks for negative support in prior distributions. |
845 | 980 |
|
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. |
850 | 985 |
|
851 | 986 | Args: |
852 | 987 | 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. |
859 | 991 | """ |
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)): |
865 | 998 | raise ValueError( |
866 | 999 | "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}." |
869 | 1002 | ) |
870 | 1003 |
|
871 | 1004 | @functools.cached_property |
|
0 commit comments