2121from ax .core .map_metric import MapMetric
2222from ax .core .optimization_config import MultiObjectiveOptimizationConfig
2323from ax .early_stopping .dispatch import get_default_ess_or_none
24- from ax .early_stopping .experiment_replay import replay_experiment
24+ from ax .early_stopping .experiment_replay import (
25+ estimate_hypothetical_early_stopping_savings ,
26+ )
2527from ax .early_stopping .strategies .base import BaseEarlyStoppingStrategy
26- from ax .early_stopping .strategies .percentile import PercentileEarlyStoppingStrategy
2728from ax .early_stopping .utils import (
2829 EARLY_STOPPING_NUDGE_MSG ,
2930 EARLY_STOPPING_NUDGE_TITLE ,
3536from ax .service .utils .early_stopping import get_early_stopping_metrics
3637from pyre_extensions import none_throws , override
3738
38- DEFAULT_MIN_SAVINGS_THRESHOLD = 0.01 # 1 % threshold
39+ DEFAULT_MIN_SAVINGS_THRESHOLD = 0.1 # 10 % threshold
3940MAX_PENDING_TRIALS_DEFAULT = 5
4041DEFAULT_EARLY_STOPPING_HEALTHCHECK_TITLE = "Early Stopping Healthcheck"
4142
@@ -92,7 +93,7 @@ def __init__(
9293 default early stopping strategy will only be used for
9394 single-objective unconstrained experiments.
9495 min_savings_threshold: Minimum savings threshold to suggest early
95- stopping. Default is 0.01 (1 % savings).
96+ stopping. Default is 0.1 (10 % savings).
9697 max_pending_trials: Maximum number of pending trials for replay
9798 orchestrator. Default is 5.
9899 auto_early_stopping_config: A string for configuring automated early
@@ -396,22 +397,40 @@ def _report_early_stopping_nudge(
396397 self , experiment : Experiment
397398 ) -> HealthcheckAnalysisCard :
398399 """Check if early stopping should be suggested (nudge) by estimating
399- hypothetical savings using replay logic."""
400- # Get map metrics from the experiment
401- # Note: validate_applicable_state already ensures map_metrics is non-empty
402- map_metrics = self ._get_map_metrics (experiment )
403-
404- # Estimate hypothetical savings for compatible metrics using replay
405- metric_to_savings = self ._estimate_hypothetical_savings_with_replay (
406- experiment = experiment , map_metrics = map_metrics
400+ hypothetical savings using replay logic.
401+
402+ Only applicable for single-objective unconstrained experiments where a
403+ default early stopping strategy is available.
404+ """
405+ opt_config = none_throws (experiment .optimization_config )
406+ metric = next (iter (opt_config .objective .metrics ))
407+ savings = estimate_hypothetical_early_stopping_savings (
408+ experiment = experiment ,
409+ metric = metric ,
410+ max_pending_trials = self .max_pending_trials ,
407411 )
408412
409- if not metric_to_savings :
410- # No significant savings detected
413+ if savings is None :
414+ # savings is None when estimate_hypothetical_early_stopping_savings
415+ # cannot compute savings. This happens for:
416+ # - Multi-objective or constrained experiments (no default ESS)
417+ # - Experiments without MapMetric data
418+ # - Experiment replay failures
419+ problem_type = self ._get_problem_type (experiment )
411420 return self ._create_card (
412421 subtitle = (
413- "Early stopping is not enabled. While this experiment has "
414- "data with a progression ('step' column) we did not detect "
422+ f"Early stopping is not enabled. Automatic early stopping "
423+ f"savings estimation is not available for this experiment "
424+ f"({ problem_type } ). If you want to use early stopping, "
425+ f"please configure an early_stopping_strategy explicitly."
426+ ),
427+ status = HealthcheckStatus .PASS ,
428+ )
429+
430+ if savings < self .min_savings_threshold :
431+ return self ._create_card (
432+ subtitle = (
433+ "Early stopping is not enabled. We did not detect "
415434 "significant potential savings at this time.\n \n "
416435 "This could be because:\n "
417436 "- The experiment hasn't run enough trials yet\n "
@@ -423,38 +442,35 @@ def _report_early_stopping_nudge(
423442 )
424443
425444 # Found significant potential savings - nudge the user
426- best_metric_name = max (metric_to_savings , key = metric_to_savings .get )
427- best_savings = metric_to_savings [best_metric_name ]
445+ savings_pct = 100 * savings
428446
429447 subtitle = EARLY_STOPPING_NUDGE_MSG .format (
430- metric_name = best_metric_name , savings = best_savings
448+ metric_name = metric . name , savings = savings_pct
431449 )
432450
433451 # Append additional info if provided
434452 if self .nudge_additional_info :
435453 subtitle += f" { self .nudge_additional_info } "
436454
437455 # Create detailed metrics table
438- metric_rows = [
439- {
440- "Metric Name" : metric_name ,
441- "Estimated Savings" : f"{ savings :.1f} %" ,
442- }
443- for metric_name , savings in sorted (
444- metric_to_savings .items (), key = lambda x : x [1 ], reverse = True
445- )
446- ]
447- df = pd .DataFrame (metric_rows )
456+ df = pd .DataFrame (
457+ [
458+ {
459+ "Metric Name" : metric .name ,
460+ "Estimated Savings" : f"{ savings_pct :.1f} %" ,
461+ }
462+ ]
463+ )
448464
449- title = EARLY_STOPPING_NUDGE_TITLE .format (savings = best_savings )
465+ title = EARLY_STOPPING_NUDGE_TITLE .format (savings = savings_pct )
450466
451467 return self ._create_card (
452468 title = title ,
453469 subtitle = subtitle ,
454470 df = df ,
455471 status = HealthcheckStatus .WARNING ,
456- potential_savings = best_savings ,
457- best_metric = best_metric_name ,
472+ potential_savings = savings_pct ,
473+ best_metric = metric . name ,
458474 )
459475
460476 def _get_problem_type (self , experiment : Experiment ) -> str :
@@ -485,63 +501,3 @@ def _get_map_metrics(self, experiment: Experiment) -> list[MapMetric]:
485501 reverse = True ,
486502 )
487503 return map_metrics
488-
489- def _estimate_hypothetical_savings_with_replay (
490- self , experiment : Experiment , map_metrics : list [MapMetric ]
491- ) -> dict [str , float ]:
492- """
493- Estimate hypothetical early stopping savings for each map metric using
494- replay infrastructure.
495-
496- This is the accurate method that replays the experiment with early stopping
497- enabled to calculate actual savings.
498-
499- Args:
500- experiment: The experiment to analyze
501- map_metrics: List of MapMetrics to analyze
502-
503- Returns:
504- Dictionary mapping metric names to estimated savings percentages
505- (only includes metrics where savings > min_savings_threshold)
506- """
507- metric_to_savings : dict [str , float ] = {}
508-
509- MAX_REPLAYS = 3
510- MAX_REPLAY_TRIALS = 50
511- REPLAY_NUM_POINTS_PER_CURVE = 20
512- REPLAY_PERCENTILE_THRESHOLD = 65
513- REPLAY_MIN_PROGRESSION_FRAC = 0.4
514- REPLAY_MIN_CURVES = 5
515-
516- # Limit to first few metrics to avoid expensive computation
517- for map_metric in map_metrics [:MAX_REPLAYS ]:
518- try :
519- # Create replayed experiment with early stopping
520- replayed_experiment = replay_experiment (
521- historical_experiment = experiment ,
522- num_samples_per_curve = REPLAY_NUM_POINTS_PER_CURVE ,
523- max_replay_trials = MAX_REPLAY_TRIALS ,
524- metric = map_metric ,
525- max_pending_trials = self .max_pending_trials ,
526- early_stopping_strategy = PercentileEarlyStoppingStrategy (
527- min_curves = REPLAY_MIN_CURVES ,
528- min_progression = REPLAY_MIN_PROGRESSION_FRAC ,
529- percentile_threshold = REPLAY_PERCENTILE_THRESHOLD ,
530- normalize_progressions = True ,
531- ),
532- )
533-
534- if replayed_experiment is not None :
535- savings = estimate_early_stopping_savings (
536- experiment = replayed_experiment
537- )
538-
539- # Only include if savings exceed threshold (> 1%)
540- if savings > self .min_savings_threshold :
541- metric_to_savings [map_metric .name ] = 100 * savings
542-
543- except Exception :
544- # Skip metrics that fail replay
545- continue
546-
547- return metric_to_savings
0 commit comments