Skip to content

Commit 03ed8b1

Browse files
andyl7anThe Meridian Authors
authored andcommitted
Expose channel parameter inspection on ModelContext and Analyzer public methods.
- Add `get_media_scaling_factor`, `get_channel_parameters`, and `get_channel_parameter_tensor` methods to `ModelContext`. - Add public methods to `Analyzer`: `get_incremental_kpi`, `incremental_outcome_xr`, `yield_batched_distribution_tensors`, and `get_kpi_means`. - Update `CHANGELOG.md`. PiperOrigin-RevId: 966778414
1 parent 1344df8 commit 03ed8b1

5 files changed

Lines changed: 1028 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,14 @@ To release a new version (e.g. from `1.0.0` -> `2.0.0`):
2323

2424
## [Unreleased]
2525

26+
* Add channel-level inspection methods (`get_media_scaling_factor`,
27+
`get_channel_parameters`, `get_channel_parameter_tensor`) and
28+
`ChannelParameters` dataclass to `ModelContext`.
29+
* Add public methods to `Analyzer`: `incremental_outcome_xr` for calculating
30+
incremental outcomes as labeled `xarray.DataArray` objects,
31+
`get_incremental_kpi`, `yield_batched_distribution_tensors`, and
32+
`get_kpi_means`.
33+
2634
## [1.8.0] - 2026-08-14
2735

2836
* Implement `reconstruction_batch_size` to chunk posterior reconstruction evaluations, fixing peak resource exhausted memory crashes on wide modeling pipelines.

meridian/analysis/analyzer.py

Lines changed: 196 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,33 @@ def _yield_batched_distribution_tensors(
230230
for k in param_list
231231
})
232232

233+
def yield_batched_distribution_tensors(
234+
self,
235+
param_list: Sequence[str],
236+
*,
237+
use_posterior: bool = True,
238+
batch_size: int = constants.DEFAULT_BATCH_SIZE,
239+
) -> Iterator[DistributionTensors]:
240+
"""Yields batched DistributionTensors for the given parameters.
241+
242+
Preconditions:
243+
The model must be fitted (i.e. posterior/prior groups must exist in the
244+
inference data). This is typically checked by the calling methods.
245+
246+
Args:
247+
param_list: Sequence of parameter names to include in the batch.
248+
use_posterior: Whether to use posterior or prior parameters.
249+
batch_size: The batch size. Must be a positive integer.
250+
251+
Yields:
252+
DistributionTensors containing the sliced parameters for the batch.
253+
"""
254+
yield from self._yield_batched_distribution_tensors(
255+
param_list=param_list,
256+
use_posterior=use_posterior,
257+
batch_size=batch_size,
258+
)
259+
233260
@backend.function(jit_compile=True)
234261
def _get_kpi_means(
235262
self,
@@ -281,6 +308,14 @@ def _get_kpi_means(
281308
)
282309
return result
283310

311+
def get_kpi_means(
312+
self,
313+
data_tensors: DataTensors,
314+
dist_tensors: DistributionTensors,
315+
) -> backend.Tensor:
316+
"""Computes batched KPI means."""
317+
return self._get_kpi_means(data_tensors, dist_tensors)
318+
284319
def _use_kpi(self, use_kpi: bool = False) -> bool:
285320
"""Checks if KPI analysis should be used.
286321
@@ -921,9 +956,7 @@ def _check_kpi_transformation(
921956
"use_kpi=False is only supported when inverse_transform_outcome=True."
922957
)
923958

924-
# TODO: Make this method public when this feature is ready for
925-
# open source.
926-
def _get_incremental_kpi(
959+
def get_incremental_kpi(
927960
self,
928961
data_tensors: DataTensors,
929962
dist_tensors: DistributionTensors,
@@ -958,8 +991,7 @@ def _get_incremental_kpi(
958991
):
959992
raise ValueError(
960993
"`non_media_treatments_baseline_normalized` must be passed to"
961-
" `_get_incremental_kpi` when `non_media_treatments` data is"
962-
" present."
994+
" `get_incremental_kpi` when `non_media_treatments` data is present."
963995
)
964996
n_media_times = self.model_context.n_media_times
965997
if data_tensors.media is not None:
@@ -1117,7 +1149,7 @@ def _incremental_outcome_impl(
11171149
" present."
11181150
)
11191151

1120-
transformed_outcome = self._get_incremental_kpi(
1152+
transformed_outcome = self.get_incremental_kpi(
11211153
data_tensors=data_tensors,
11221154
dist_tensors=dist_tensors,
11231155
non_media_treatments_baseline_normalized=non_media_treatments_baseline_normalized,
@@ -1408,6 +1440,164 @@ def incremental_outcome(
14081440
incremental_outcome_temps.append(batch_incremental_outcome)
14091441
return backend.concatenate(incremental_outcome_temps, axis=1)
14101442

1443+
def incremental_outcome_xr(
1444+
self,
1445+
use_posterior: bool = True,
1446+
*,
1447+
new_data: DataTensors | None = None,
1448+
non_media_baseline_values: Sequence[float] | None = None,
1449+
scaling_factor0: float = 0.0,
1450+
scaling_factor1: float = 1.0,
1451+
selected_geos: Sequence[str] | None = None,
1452+
selected_times: Sequence[str] | Sequence[bool] | None = None,
1453+
media_selected_times: Sequence[str] | Sequence[bool] | None = None,
1454+
aggregate_geos: bool = False,
1455+
aggregate_times: bool = False,
1456+
inverse_transform_outcome: bool = True,
1457+
use_kpi: bool = False,
1458+
by_reach: bool = True,
1459+
include_non_paid_channels: bool = True,
1460+
batch_size: int = constants.DEFAULT_BATCH_SIZE,
1461+
) -> xr.DataArray:
1462+
"""Calculates the incremental outcome as an xarray.DataArray.
1463+
1464+
This is a sister method to `incremental_outcome` that returns an
1465+
`xarray.DataArray` instead of a `backend.Tensor`. This allows users to
1466+
easily combine results from different models by leveraging xarray's
1467+
automatic alignment by coordinates.
1468+
1469+
Args:
1470+
use_posterior: If `True`, then the incremental outcome posterior
1471+
distribution is calculated. Otherwise, the prior distribution is
1472+
calculated.
1473+
new_data: Optional `DataTensors` container.
1474+
non_media_baseline_values: Optional sequence of baseline values.
1475+
scaling_factor0: Scaling factor for counterfactual scenario 0.
1476+
scaling_factor1: Scaling factor for counterfactual scenario 1.
1477+
selected_geos: Optional sequence containing a subset of geos to include.
1478+
selected_times: Optional sequence containing either a subset of dates to
1479+
include or booleans.
1480+
media_selected_times: Optional sequence containing either a subset of
1481+
dates to include or booleans.
1482+
aggregate_geos: If `True`, then incremental outcome is summed over all
1483+
regions. Defaults to `False` in this method to preserve dimensions.
1484+
aggregate_times: If `True`, then incremental outcome is summed over all
1485+
time periods. Defaults to `False` in this method to preserve dimensions.
1486+
inverse_transform_outcome: Whether to inverse transform the outcome.
1487+
use_kpi: Whether to use KPI instead of revenue.
1488+
by_reach: Whether to calculate by reach.
1489+
include_non_paid_channels: Whether to include non-paid channels.
1490+
batch_size: Maximum draws per chain in each batch.
1491+
1492+
Returns:
1493+
An `xarray.DataArray` of incremental outcome with labeled dimensions and
1494+
coordinates.
1495+
"""
1496+
outcome_tensor = self.incremental_outcome(
1497+
use_posterior=use_posterior,
1498+
new_data=new_data,
1499+
non_media_baseline_values=non_media_baseline_values,
1500+
scaling_factor0=scaling_factor0,
1501+
scaling_factor1=scaling_factor1,
1502+
selected_geos=selected_geos,
1503+
selected_times=selected_times,
1504+
media_selected_times=media_selected_times,
1505+
aggregate_geos=aggregate_geos,
1506+
aggregate_times=aggregate_times,
1507+
inverse_transform_outcome=inverse_transform_outcome,
1508+
use_kpi=use_kpi,
1509+
by_reach=by_reach,
1510+
include_non_paid_channels=include_non_paid_channels,
1511+
batch_size=batch_size,
1512+
)
1513+
1514+
def _get_dims() -> list[str]:
1515+
return [
1516+
constants.CHAIN,
1517+
constants.DRAW,
1518+
*([constants.GEO] if not aggregate_geos else []),
1519+
*([constants.TIME] if not aggregate_times else []),
1520+
constants.CHANNEL,
1521+
]
1522+
1523+
def _get_coords(dims: Sequence[str]) -> dict[str, Any]:
1524+
"""Returns a dictionary of coordinates for the xarray.DataArray.
1525+
1526+
Args:
1527+
dims: The dimensions of the xarray.DataArray.
1528+
1529+
Returns:
1530+
A dictionary of coordinates for the xarray.DataArray.
1531+
"""
1532+
params = (
1533+
self.inference_data.posterior # pyrefly: ignore[missing-attribute]
1534+
if use_posterior
1535+
else self.inference_data.prior # pyrefly: ignore[missing-attribute]
1536+
)
1537+
n_draws = params.draw.size
1538+
n_chains = params.chain.size
1539+
1540+
coords = self.model_context.create_inference_data_coords(
1541+
n_chains, n_draws
1542+
)
1543+
1544+
channels = (
1545+
self.model_context.input_data.get_all_channels()
1546+
if include_non_paid_channels
1547+
else self.model_context.input_data.get_all_paid_channels()
1548+
)
1549+
1550+
geo_coords = {}
1551+
if constants.GEO in dims:
1552+
if selected_geos is not None:
1553+
geo_coords[constants.GEO] = self.model_context.input_data.geo.values[
1554+
np.isin(
1555+
self.model_context.input_data.geo.values,
1556+
selected_geos,
1557+
)
1558+
]
1559+
else:
1560+
geo_coords[constants.GEO] = coords[constants.GEO]
1561+
1562+
time_coords = {}
1563+
if constants.TIME in dims:
1564+
time_idx = dims.index(constants.TIME)
1565+
time_dim_size = outcome_tensor.shape[time_idx]
1566+
1567+
if selected_times is None:
1568+
# Fallback to original times if size matches.
1569+
# TODO: Support `new_data.time` properly.
1570+
if time_dim_size == len(coords[constants.TIME]):
1571+
time_coords[constants.TIME] = np.asarray(coords[constants.TIME])
1572+
elif all(isinstance(t, str) for t in selected_times):
1573+
filtered_times = self.model_context.input_data.time.values[
1574+
np.isin(self.model_context.input_data.time.values, selected_times)
1575+
]
1576+
if len(filtered_times) == time_dim_size:
1577+
time_coords[constants.TIME] = filtered_times
1578+
elif len(coords[constants.TIME]) == len(selected_times):
1579+
# Boolean mask
1580+
time_coords[constants.TIME] = np.array(coords[constants.TIME])[
1581+
np.array(selected_times)
1582+
]
1583+
1584+
return {
1585+
constants.CHAIN: coords[constants.CHAIN],
1586+
constants.DRAW: coords[constants.DRAW],
1587+
constants.CHANNEL: channels,
1588+
**geo_coords,
1589+
**time_coords,
1590+
}
1591+
1592+
dims = _get_dims()
1593+
coords_dict = _get_coords(dims)
1594+
1595+
return xr.DataArray(
1596+
data=np.asarray(outcome_tensor),
1597+
dims=dims,
1598+
coords=coords_dict,
1599+
)
1600+
14111601
def _validate_geo_and_time_granularity(
14121602
self,
14131603
selected_geos: Sequence[str] | None = None,

0 commit comments

Comments
 (0)