alchemist-nrel 0.2.1__py3-none-any.whl → 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. alchemist_core/__init__.py +14 -7
  2. alchemist_core/acquisition/botorch_acquisition.py +14 -6
  3. alchemist_core/audit_log.py +594 -0
  4. alchemist_core/data/experiment_manager.py +69 -5
  5. alchemist_core/models/botorch_model.py +6 -4
  6. alchemist_core/models/sklearn_model.py +44 -6
  7. alchemist_core/session.py +600 -8
  8. alchemist_core/utils/doe.py +200 -0
  9. {alchemist_nrel-0.2.1.dist-info → alchemist_nrel-0.3.0.dist-info}/METADATA +57 -40
  10. alchemist_nrel-0.3.0.dist-info/RECORD +66 -0
  11. {alchemist_nrel-0.2.1.dist-info → alchemist_nrel-0.3.0.dist-info}/entry_points.txt +1 -0
  12. {alchemist_nrel-0.2.1.dist-info → alchemist_nrel-0.3.0.dist-info}/top_level.txt +1 -0
  13. api/main.py +19 -3
  14. api/models/requests.py +71 -0
  15. api/models/responses.py +144 -0
  16. api/routers/experiments.py +117 -5
  17. api/routers/sessions.py +329 -10
  18. api/routers/visualizations.py +10 -5
  19. api/services/session_store.py +210 -54
  20. api/static/NEW_ICON.ico +0 -0
  21. api/static/NEW_ICON.png +0 -0
  22. api/static/NEW_LOGO_DARK.png +0 -0
  23. api/static/NEW_LOGO_LIGHT.png +0 -0
  24. api/static/assets/api-vcoXEqyq.js +1 -0
  25. api/static/assets/index-C0_glioA.js +4084 -0
  26. api/static/assets/index-CB4V1LI5.css +1 -0
  27. api/static/index.html +14 -0
  28. api/static/vite.svg +1 -0
  29. run_api.py +55 -0
  30. ui/gpr_panel.py +7 -2
  31. ui/notifications.py +197 -10
  32. ui/ui.py +1117 -68
  33. ui/variables_setup.py +47 -2
  34. ui/visualizations.py +60 -3
  35. alchemist_nrel-0.2.1.dist-info/RECORD +0 -54
  36. {alchemist_nrel-0.2.1.dist-info → alchemist_nrel-0.3.0.dist-info}/WHEEL +0 -0
  37. {alchemist_nrel-0.2.1.dist-info → alchemist_nrel-0.3.0.dist-info}/licenses/LICENSE +0 -0
@@ -13,13 +13,15 @@ class ExperimentManager:
13
13
  self.df = pd.DataFrame() # Raw experimental data
14
14
  self.search_space = search_space # Reference to the search space
15
15
  self.filepath = None # Path to saved experiment file
16
+ self._current_iteration = 0 # Track current iteration for audit log
16
17
 
17
18
  def set_search_space(self, search_space):
18
19
  """Set or update the search space reference."""
19
20
  self.search_space = search_space
20
21
 
21
22
  def add_experiment(self, point_dict: Dict[str, Union[float, str, int]], output_value: Optional[float] = None,
22
- noise_value: Optional[float] = None):
23
+ noise_value: Optional[float] = None, iteration: Optional[int] = None,
24
+ reason: Optional[str] = None):
23
25
  """
24
26
  Add a single experiment point.
25
27
 
@@ -27,6 +29,8 @@ class ExperimentManager:
27
29
  point_dict: Dictionary with variable names as keys and values
28
30
  output_value: The experiment output/target value (if known)
29
31
  noise_value: Optional observation noise/uncertainty value for regularization
32
+ iteration: Iteration number (auto-assigned if None)
33
+ reason: Reason for this experiment (e.g., 'Initial Design (LHS)', 'Expected Improvement')
30
34
  """
31
35
  # Create a copy of the point_dict to avoid modifying the original
32
36
  new_point = point_dict.copy()
@@ -38,6 +42,22 @@ class ExperimentManager:
38
42
  # Add noise value if provided
39
43
  if noise_value is not None:
40
44
  new_point['Noise'] = noise_value
45
+
46
+ # Add iteration tracking
47
+ if iteration is not None:
48
+ # Use provided iteration and ensure _current_iteration reflects it
49
+ new_point['Iteration'] = int(iteration)
50
+ # Keep _current_iteration in sync with the latest explicit iteration
51
+ try:
52
+ self._current_iteration = int(iteration)
53
+ except Exception:
54
+ pass
55
+ else:
56
+ # Use current iteration (doesn't increment until lock_acquisition)
57
+ new_point['Iteration'] = int(self._current_iteration)
58
+
59
+ # Add reason
60
+ new_point['Reason'] = reason if reason is not None else 'Manual'
41
61
 
42
62
  # Convert to DataFrame and append
43
63
  new_df = pd.DataFrame([new_point])
@@ -52,6 +72,20 @@ class ExperimentManager:
52
72
  if missing_cols:
53
73
  raise ValueError(f"DataFrame is missing required columns: {missing_cols}")
54
74
 
75
+ # Ensure each row has an Iteration value; default to current iteration
76
+ if 'Iteration' not in data_df.columns:
77
+ data_df = data_df.copy()
78
+ data_df['Iteration'] = int(self._current_iteration)
79
+ else:
80
+ # Fill missing iterations with current iteration
81
+ data_df = data_df.copy()
82
+ data_df['Iteration'] = pd.to_numeric(data_df['Iteration'], errors='coerce').fillna(self._current_iteration).astype(int)
83
+ # Update _current_iteration to the max iteration present
84
+ if len(data_df) > 0:
85
+ max_iter = int(data_df['Iteration'].max())
86
+ if max_iter > self._current_iteration:
87
+ self._current_iteration = max_iter
88
+
55
89
  # Append the data
56
90
  self.df = pd.concat([self.df, data_df], ignore_index=True)
57
91
 
@@ -69,8 +103,17 @@ class ExperimentManager:
69
103
  """
70
104
  if 'Output' not in self.df.columns:
71
105
  raise ValueError("DataFrame doesn't contain 'Output' column")
72
-
73
- X = self.df.drop(columns=['Output'] + (['Noise'] if 'Noise' in self.df.columns else []))
106
+
107
+ # Drop metadata columns (Output, Noise, Iteration, Reason)
108
+ metadata_cols = ['Output']
109
+ if 'Noise' in self.df.columns:
110
+ metadata_cols.append('Noise')
111
+ if 'Iteration' in self.df.columns:
112
+ metadata_cols.append('Iteration')
113
+ if 'Reason' in self.df.columns:
114
+ metadata_cols.append('Reason')
115
+
116
+ X = self.df.drop(columns=metadata_cols)
74
117
  y = self.df['Output']
75
118
  return X, y
76
119
 
@@ -85,8 +128,17 @@ class ExperimentManager:
85
128
  """
86
129
  if 'Output' not in self.df.columns:
87
130
  raise ValueError("DataFrame doesn't contain 'Output' column")
88
-
89
- X = self.df.drop(columns=['Output'] + (['Noise'] if 'Noise' in self.df.columns else []))
131
+
132
+ # Drop metadata columns
133
+ metadata_cols = ['Output']
134
+ if 'Noise' in self.df.columns:
135
+ metadata_cols.append('Noise')
136
+ if 'Iteration' in self.df.columns:
137
+ metadata_cols.append('Iteration')
138
+ if 'Reason' in self.df.columns:
139
+ metadata_cols.append('Reason')
140
+
141
+ X = self.df.drop(columns=metadata_cols)
90
142
  y = self.df['Output']
91
143
  noise = self.df['Noise'] if 'Noise' in self.df.columns else None
92
144
  return X, y, noise
@@ -129,6 +181,18 @@ class ExperimentManager:
129
181
  print("Warning: Noise column contains non-numeric values. Converting to default noise level.")
130
182
  self.df['Noise'] = 1e-10 # Default small noise
131
183
 
184
+ # Initialize iteration tracking from data
185
+ if 'Iteration' in self.df.columns:
186
+ self._current_iteration = int(self.df['Iteration'].max())
187
+ else:
188
+ # Add iteration column if missing (legacy data)
189
+ self.df['Iteration'] = 0
190
+ self._current_iteration = 0
191
+
192
+ # Add reason column if missing (legacy data)
193
+ if 'Reason' not in self.df.columns:
194
+ self.df['Reason'] = 'Initial Design'
195
+
132
196
  return self
133
197
 
134
198
  @classmethod
@@ -485,8 +485,10 @@ class BoTorchModel(BaseModel):
485
485
  outcome_transform=fold_outcome_transform
486
486
  )
487
487
 
488
- # Load the trained state - this keeps the hyperparameters without retraining
489
- fold_model.load_state_dict(self.fitted_state_dict, strict=False)
488
+ # Train the fold model from scratch (don't load state_dict to avoid dimension mismatches)
489
+ # This is necessary because folds may have different categorical values or data shapes
490
+ mll = ExactMarginalLogLikelihood(fold_model.likelihood, fold_model)
491
+ fit_gpytorch_mll(mll)
490
492
 
491
493
  # Make predictions on test fold
492
494
  fold_model.eval()
@@ -720,8 +722,8 @@ class BoTorchModel(BaseModel):
720
722
  y_vals = torch.linspace(y_range[0], y_range[1], 100)
721
723
  X, Y = torch.meshgrid(x_vals, y_vals, indexing='ij')
722
724
 
723
- # Total dimensions in the model
724
- input_dim = len(self.feature_names) if self.feature_names else 4
725
+ # Total dimensions in the model (use original_feature_names to match actual input dimensions)
726
+ input_dim = len(self.original_feature_names) if self.original_feature_names else 2
725
727
 
726
728
  # Create placeholder tensors for all dimensions
727
729
  grid_tensors = []
@@ -85,9 +85,30 @@ class SklearnModel(BaseModel):
85
85
  def _build_kernel(self, X):
86
86
  """Build the kernel using training data X to initialize length scales."""
87
87
  kernel_type = self.kernel_options.get("kernel_type", "RBF")
88
- # Compute initial length scales as the mean of the data along each dimension.
89
- ls_init = np.mean(X, axis=0)
90
- ls_bounds = [(1e-5, l * 1e5) for l in ls_init]
88
+ # Compute initial length scales from the data.
89
+ # Use standard deviation (positive) as a robust length-scale initializer.
90
+ try:
91
+ ls_init = np.std(X, axis=0)
92
+ ls_init = np.array(ls_init, dtype=float)
93
+ # Replace non-finite or non-positive values with sensible defaults
94
+ bad_mask = ~np.isfinite(ls_init) | (ls_init <= 0)
95
+ if np.any(bad_mask):
96
+ logger.debug("Replacing non-finite or non-positive length-scales with 1.0")
97
+ ls_init[bad_mask] = 1.0
98
+
99
+ # Build finite, positive bounds for each length-scale
100
+ ls_bounds = []
101
+ for l in ls_init:
102
+ # Protect against extremely small or non-finite upper bounds
103
+ upper = float(l * 1e5) if np.isfinite(l) else 1e5
104
+ if not np.isfinite(upper) or upper <= 1e-8:
105
+ upper = 1e3
106
+ ls_bounds.append((1e-5, upper))
107
+ except Exception as e:
108
+ logger.warning(f"Failed to compute sensible length-scales from data: {e}. Using safe defaults.")
109
+ n_dims = X.shape[1] if hasattr(X, 'shape') else 1
110
+ ls_init = np.ones(n_dims, dtype=float)
111
+ ls_bounds = [(1e-5, 1e5) for _ in range(n_dims)]
91
112
  constant = C()
92
113
  if kernel_type == "RBF":
93
114
  kernel = constant * RBF(length_scale=ls_init, length_scale_bounds=ls_bounds)
@@ -317,12 +338,29 @@ class SklearnModel(BaseModel):
317
338
 
318
339
  # Create model with appropriate parameters
319
340
  self.model = GaussianProcessRegressor(**params)
320
-
341
+
321
342
  # Store the raw training data for possible reuse with skopt
322
343
  self.X_train_ = X
323
344
  self.y_train_ = y
324
-
325
- self.model.fit(X, y)
345
+
346
+ # Fit the model, but be defensive: if sklearn complains about non-finite
347
+ # bounds when n_restarts_optimizer>0, retry with no restarts.
348
+ try:
349
+ self.model.fit(X, y)
350
+ except ValueError as e:
351
+ msg = str(e)
352
+ if 'requires that all bounds are finite' in msg or 'bounds' in msg.lower():
353
+ logger.warning("GaussianProcessRegressor failed due to non-finite bounds. "
354
+ "Retrying without optimizer restarts (n_restarts_optimizer=0).")
355
+ # Retry with safer parameters
356
+ safe_params = params.copy()
357
+ safe_params['n_restarts_optimizer'] = 0
358
+ safe_params['optimizer'] = None
359
+ self.model = GaussianProcessRegressor(**safe_params)
360
+ self.model.fit(X, y)
361
+ else:
362
+ # Re-raise other value errors
363
+ raise
326
364
  self.optimized_kernel = self.model.kernel_
327
365
  self._is_trained = True
328
366