@@ -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