|
1 | 1 | """Helpers for working with the Ax Experiment class.""" |
2 | 2 |
|
| 3 | +from typing import TypeAlias, TypedDict, cast |
| 4 | + |
3 | 5 | import pandas as pd |
4 | 6 | from ax import Experiment |
5 | 7 |
|
| 8 | +from axtreme.experiment import add_metric_data_to_experiment |
| 9 | + |
6 | 10 |
|
7 | 11 | def input_out_df_from_experiment(exp: Experiment) -> pd.DataFrame: |
8 | 12 | """Shows the x and y data in one dataframe.""" |
9 | 13 | df_y = exp.fetch_data().df |
10 | 14 | df_x = pd.DataFrame([{**trial.arm.parameters, "arm_name": trial.arm.name} for trial in exp.trials.values()]) # pyright: ignore[reportAttributeAccessIssue] |
11 | 15 | return df_y.merge(df_x, on="arm_name", how="inner") |
| 16 | + |
| 17 | + |
| 18 | +class _MetricValueData(TypedDict): |
| 19 | + mean: float | None |
| 20 | + sem: float | None |
| 21 | + |
| 22 | + |
| 23 | +# Key is the parameter name |
| 24 | +_ParametersData: TypeAlias = dict[str, None | str | bool | float | int] |
| 25 | +# Key is the metric name |
| 26 | +_MetricData: TypeAlias = dict[str, _MetricValueData] |
| 27 | + |
| 28 | + |
| 29 | +class _ArmData(TypedDict): |
| 30 | + parameters: _ParametersData |
| 31 | + metrics: _MetricData |
| 32 | + |
| 33 | + |
| 34 | +# ExperimentRunsData stores the details (x location, result) of all the completed trials an experiment has run. |
| 35 | +# Structure: dict[<trial_index_str>, dict[<arm_name>, _ArmData]] |
| 36 | +# NOTE: We have created this data structure for our own convenience. It is not part of or related to the ax library. |
| 37 | +# TODO(sw 2026-06-14): This could be upgraded to use Ax types (e.g. axtreme.core.types.TParameterization) |
| 38 | +ExperimentRunsData: TypeAlias = dict[str, dict[str, _ArmData]] |
| 39 | + |
| 40 | + |
| 41 | +def extract_data_from_experiment_as_json( |
| 42 | + experiment: Experiment, |
| 43 | +) -> ExperimentRunsData: |
| 44 | + """Extracts the details (x location, results) of the completed trials of an Ax Experiment. |
| 45 | +
|
| 46 | + This does not serialize the full Experiment. The information extracted is the minimum necessary for these trial runs |
| 47 | + to be added to another experiment. |
| 48 | +
|
| 49 | + Args: |
| 50 | + experiment: The Ax experiment to extract the data from. |
| 51 | +
|
| 52 | + Returns: |
| 53 | + A dictionary with the following structure: |
| 54 | +
|
| 55 | + .. code-block:: json |
| 56 | +
|
| 57 | + { |
| 58 | + <trial_index_str>: { |
| 59 | + "<arm_name>": { |
| 60 | + "parameters": { |
| 61 | + "<parameter_name>": <parameter_value> |
| 62 | + }, |
| 63 | + "metrics": { |
| 64 | + "<metric_name>": { |
| 65 | + "mean": 0.0, |
| 66 | + "sem": 0.0 |
| 67 | + } |
| 68 | + } |
| 69 | + } |
| 70 | + } |
| 71 | + } |
| 72 | + """ |
| 73 | + metric_data = experiment.fetch_data().df |
| 74 | + trial_indexes = metric_data[["trial_index"]].drop_duplicates() |
| 75 | + trials = experiment.get_trials_by_indices(trial_indexes["trial_index"].values) |
| 76 | + |
| 77 | + trial_data: ExperimentRunsData = {} |
| 78 | + |
| 79 | + metric_data_by_arm = metric_data.groupby("arm_name") |
| 80 | + |
| 81 | + for trial in trials: |
| 82 | + trial_index = str(trial.index) |
| 83 | + if trial_index not in trial_data: |
| 84 | + # Keys in json dictionaries must be strings. |
| 85 | + trial_data[trial_index] = {} |
| 86 | + # BatchTrails can have multiple arms |
| 87 | + for arm in trial.arms: |
| 88 | + if arm.name not in trial_data[trial_index]: |
| 89 | + trial_data[trial_index][arm.name] = { |
| 90 | + "parameters": {}, |
| 91 | + "metrics": {}, |
| 92 | + } |
| 93 | + |
| 94 | + for parameter_name, parameter_value in arm.parameters.items(): |
| 95 | + trial_data[trial_index][arm.name]["parameters"][parameter_name] = parameter_value |
| 96 | + |
| 97 | + arm_metric_data = metric_data_by_arm.get_group(arm.name)[["metric_name", "mean", "sem"]] |
| 98 | + for metric_name, mean, sem in arm_metric_data.to_numpy(): |
| 99 | + trial_data[trial_index][arm.name]["metrics"][metric_name] = {"mean": mean, "sem": sem} |
| 100 | + |
| 101 | + return trial_data |
| 102 | + |
| 103 | + |
| 104 | +def add_json_data_to_experiment(experiment: Experiment, json_data: ExperimentRunsData) -> None: |
| 105 | + """Adds the data from a dictionary to an ax Experiment. |
| 106 | +
|
| 107 | + Args: |
| 108 | + experiment: The ax Experiment to add the data to. |
| 109 | + json_data: The data to add to the ax Experiment. The structure should be the same as the output of |
| 110 | + extract_data_from_experiment. |
| 111 | +
|
| 112 | + Example: |
| 113 | + .. code-block:: json |
| 114 | +
|
| 115 | + { |
| 116 | + <trial_index_str>: { |
| 117 | + "<arm_name>": { |
| 118 | + "parameters": { |
| 119 | + "<parameter_name>": <parameter_value> |
| 120 | + }, |
| 121 | + "metrics": { |
| 122 | + "<metric_name>": { |
| 123 | + "mean": 0.0, |
| 124 | + "sem": 0.0 |
| 125 | + } |
| 126 | + } |
| 127 | + } |
| 128 | + } |
| 129 | + } |
| 130 | + """ |
| 131 | + # For each arm in the dictionary, extract the parameters and metrics for all arms in the trial |
| 132 | + for trial_arms in json_data.values(): |
| 133 | + # If a single trial has multiple arms, a batch trial is created. |
| 134 | + trial_parameters: list[_ParametersData] = [arm_data["parameters"] for arm_data in trial_arms.values()] |
| 135 | + |
| 136 | + # add_metric_data_to_experiment's metric_data param is a broader union (it also accepts float/tuple |
| 137 | + # forms from other callers) than the _MetricValueData TypedDict -- cast to bridge the two. |
| 138 | + trial_metrics: list[dict[str, dict[str, float | None]]] = [ |
| 139 | + cast("dict[str, dict[str, float | None]]", arm_data["metrics"]) for arm_data in trial_arms.values() |
| 140 | + ] |
| 141 | + _ = add_metric_data_to_experiment( |
| 142 | + experiment=experiment, |
| 143 | + parameterizations=trial_parameters, |
| 144 | + metric_data=trial_metrics, |
| 145 | + ) |
0 commit comments