forked from facebook/Ax
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathanalysis.py
More file actions
242 lines (213 loc) · 8.17 KB
/
analysis.py
File metadata and controls
242 lines (213 loc) · 8.17 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
# Copyright (c) Meta Platforms, Inc. and affiliates.
#
# This source code is licensed under the MIT license found in the
# LICENSE file in the root directory of this source tree.
# pyre-strict
from __future__ import annotations
import traceback
from collections.abc import Sequence
from logging import Logger
from typing import Protocol
import pandas as pd
from ax.adapter.base import Adapter
from ax.core.analysis_card import (
AnalysisCard,
AnalysisCardBase,
AnalysisCardGroup,
ErrorAnalysisCard,
NotApplicableStateAnalysisCard,
)
from ax.core.experiment import Experiment
from ax.exceptions.analysis import AnalysisNotApplicableStateError
from ax.generation_strategy.generation_strategy import GenerationStrategy
from ax.utils.common.logger import get_logger
from ax.utils.common.result import Err, ExceptionE, Ok, Result
from IPython.display import display
logger: Logger = get_logger(__name__)
NOT_APPLICABLE_STATE_SUBTITLE: str = (
"This analysis is temporarily unavailable. It will become available "
"as your experiment progresses (e.g., after collecting more data, "
"running more trials, or fitting a model)."
)
class Analysis(Protocol):
"""
An Analysis is a class that given either and Experiment, a GenerationStrategy, or
both can compute some data intended for end-user consumption. The data is returned
to the user in the form of an AnalysisCard which contains the raw data, a blob (the
data processed for end-user consumption), and miscellaneous metadata that can be
useful for rendering the card or a collection of cards.
The AnalysisCard is a thin wrapper around the raw data and the processed blob;
Analyses impose structure on their blob should subclass Analysis. See
PlotlyAnalysis for an example which produces cards where the blob is always a
Plotly Figure object.
A good pattern to follow when implementing your own Analyses is to configure
"settings" (like which parameter or metrics to operate on, or whether to use
observed or modeled effects) in your Analyses' __init__ methods, then to consume
these settings in the compute method.
"""
def compute(
self,
experiment: Experiment | None = None,
generation_strategy: GenerationStrategy | None = None,
adapter: Adapter | None = None,
) -> AnalysisCardBase:
# Note: when implementing compute always prefer experiment.lookup_data() to
# experiment.fetch_data() to avoid unintential data fetching within the report
# generation.
...
def validate_applicable_state(
self,
experiment: Experiment | None = None,
generation_strategy: GenerationStrategy | None = None,
adapter: Adapter | None = None,
) -> str | None:
"""
Validates that the Experiment, GenerationStrategy, and/or Adapter are in an
applicable state to compute this Analysis for its given settings. If the state
is not applicable, returns a string describing why; if the state is applicable,
returns None.
Example: if ArmEffectsPlot(metric_name="foo").validate_applicable_state(...) is
called on an Experiment with no data for metric "foo", it will return a string
clearly stating that the Experiment is still waiting for data for "foo".
"""
...
def compute_result(
self,
experiment: Experiment | None = None,
generation_strategy: GenerationStrategy | None = None,
adapter: Adapter | None = None,
) -> Result[AnalysisCardBase, AnalysisE]:
"""
Utility method to compute an AnalysisCard as a Result. This can be useful for
computing many Analyses at once and handling Exceptions later.
"""
not_applicable_explanation = self.validate_applicable_state(
experiment=experiment,
generation_strategy=generation_strategy,
adapter=adapter,
)
if not_applicable_explanation is not None:
return Err(
value=AnalysisE(
message="Analysis is not applicable to given state",
exception=AnalysisNotApplicableStateError(
not_applicable_explanation
),
analysis=self,
)
)
try:
card = self.compute(
experiment=experiment,
generation_strategy=generation_strategy,
adapter=adapter,
)
return Ok(value=card)
except Exception as e:
logger.error(f"Failed to compute {self.__class__.__name__}")
logger.error(traceback.format_exc())
return Err(
value=AnalysisE(
message=f"Failed to compute {self.__class__.__name__}",
exception=e,
analysis=self,
)
)
def compute_or_error_card(
self,
experiment: Experiment | None = None,
generation_strategy: GenerationStrategy | None = None,
adapter: Adapter | None = None,
) -> AnalysisCardBase:
"""
Utility method to compute an AnalysisCard or an ErrorAnalysisCard if an
exception is raised.
"""
return self.compute_result(
experiment=experiment,
generation_strategy=generation_strategy,
adapter=adapter,
).unwrap_or_else(error_card_from_analysis_e)
def _create_analysis_card(
self,
title: str,
subtitle: str,
df: pd.DataFrame,
) -> AnalysisCard:
"""
Make an AnalysisCard from this Analysis using provided fields and
details about the Analysis class.
"""
return AnalysisCard(
name=self.__class__.__name__,
title=title,
subtitle=subtitle,
df=df,
blob=df.to_json(),
)
def _create_analysis_card_group(
self,
title: str,
subtitle: str | None,
children: Sequence[AnalysisCardBase],
) -> AnalysisCardGroup:
"""
Make an AnalysisCardGroup from this Analysis using provided fields and
details about the Analysis class.
"""
return AnalysisCardGroup(
name=self.__class__.__name__,
title=title,
subtitle=subtitle if subtitle is not None else "",
children=children,
)
class AnalysisE(ExceptionE):
analysis: Analysis
def __init__(
self,
message: str,
exception: Exception,
analysis: Analysis,
) -> None:
super().__init__(message, exception)
self.analysis = analysis
def display_cards(
cards: Sequence[AnalysisCardBase],
) -> None:
"""
Helper method for displaying a sequence of AnalysisCards (as is returned by adhoc
compute_ methods and by Client.compute_analyses).
"""
for card in cards:
display(card)
def error_card_from_analysis_e(
analysis_e: AnalysisE,
) -> ErrorAnalysisCard | NotApplicableStateAnalysisCard:
analysis_name = analysis_e.analysis.__class__.__name__
exception_name = analysis_e.exception.__class__.__name__
# Include the exception message in the subtitle if available, so users can
# see the reasoning in the error card.
subtitle = (
f"{exception_name}: {exception_message}"
if (exception_message := str(analysis_e.exception))
else f"{exception_name} encountered while computing {analysis_name}."
)
if isinstance(analysis_e.exception, AnalysisNotApplicableStateError):
# AnalysisNotApplicableStateError gets rendered as a
# NotApplicableStateAnalysisCard
return NotApplicableStateAnalysisCard(
name=analysis_name,
title=f"{analysis_name} -- Not Available Yet",
subtitle=NOT_APPLICABLE_STATE_SUBTITLE,
df=pd.DataFrame(),
blob=exception_message
if exception_message
else f"{exception_name} encountered.",
)
return ErrorAnalysisCard(
name=analysis_name,
title=f"{analysis_name} Error",
subtitle=subtitle,
df=pd.DataFrame(),
blob=analysis_e.tb_str() or "",
)