Coverage for ml_workbench/experiment.py: 14%

163 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2026-01-06 16:09 +0200

1from __future__ import annotations 

2 

3from collections.abc import Mapping, Sequence 

4from dataclasses import dataclass 

5from typing import TYPE_CHECKING, Any 

6import warnings 

7 

8import pandas as pd 

9 

10if TYPE_CHECKING: 10 ↛ 11line 10 didn't jump to line 11 because the condition on line 10 was never true

11 from .config import YamlConfig 

12 

13# Valid experiment types 

14VALID_EXPERIMENT_TYPES = ["regression", "classification"] 

15 

16 

17@dataclass(frozen=True) 

18class ExperimentSpec: 

19 name: str 

20 description: str | None 

21 models: list[str] 

22 dataset: str 

23 target: str 

24 features: str 

25 do_not_split_by: list[str] 

26 metrics: list[str] 

27 hold_out: dict[str, Any] 

28 drop_outliers: float | None 

29 type: str | None 

30 

31 

32class Experiment: 

33 """Encapsulates an experiment definition from configuration. 

34 

35 Expected structure under ``experiments``: 

36 

37 experiments: 

38 exp_name: 

39 description: description_text # optional 

40 models: model_name or [model1, model2] # required (one or many model names) 

41 dataset: dataset_name # required (one dataset name) 

42 target: target_name # required (one target name) 

43 features: feature_name # required (single feature name) 

44 type: regression or classification # optional (experiment type) 

45 do_not_split_by: [col] # optional (one or many column names) 

46 metrics: metric_name or [metric1, metric2] # optional (one or many metric names) 

47 hold_out: { ... } # optional 

48 drop_outliers: 3.0 or 0.0 or false # optional (default: 3.0, disable with 0.0 or false) 

49 

50 Note: The 'split' component is deprecated and will be ignored if present. 

51 Use 'hold_out' instead for creating holdout sets. 

52 

53 If ``type`` is not specified, it will remain None until inference in the Runner, 

54 where it will be inferred from the target column type: 

55 - If target is numeric -> type="regression" 

56 - If target is categorical -> type="classification" 

57 """ 

58 

59 def __init__(self, config: YamlConfig, name: str | None = None) -> None: 

60 self.config = config 

61 

62 experiments = self._get_experiments_section(config) 

63 

64 # If name is None, use the first experiment in the config 

65 if name is None: 

66 if not experiments: 

67 raise KeyError("No experiments found in configuration") # noqa: TRY003 

68 name = next(iter(experiments)) 

69 

70 if name not in experiments: 

71 raise KeyError(f"Experiment '{name}' not found in configuration") # noqa: TRY003 

72 

73 self.name = name 

74 raw = experiments[name] 

75 if not isinstance(raw, Mapping): 

76 raise TypeError(f"Experiment '{name}' specification must be a mapping") # noqa: TRY003 

77 

78 description = raw.get("description") 

79 models = raw.get("models") 

80 dataset = raw.get("dataset") 

81 target = raw.get("target") 

82 features = raw.get("features") 

83 

84 if isinstance(models, str): 

85 models_list = [models] 

86 elif isinstance(models, Sequence): 

87 models_list = [str(m) for m in list(models)] 

88 else: 

89 raise TypeError( # noqa: TRY003 

90 f"Experiment '{name}' missing required 'models' (str or list)" 

91 ) 

92 

93 if not isinstance(dataset, str) or not dataset: 

94 raise ValueError(f"Experiment '{name}' missing required 'dataset' string") # noqa: TRY003 

95 

96 if not isinstance(target, str) or not target: 

97 raise ValueError(f"Experiment '{name}' missing required 'target' string") # noqa: TRY003 

98 

99 if not isinstance(features, str) or not features: 

100 raise ValueError(f"Experiment '{name}' missing required 'features' string") # noqa: TRY003 

101 

102 do_not_split_by = raw.get("do_not_split_by", []) 

103 if isinstance(do_not_split_by, str): 

104 do_not_split_by_list = [do_not_split_by] 

105 elif isinstance(do_not_split_by, Sequence): 

106 do_not_split_by_list = [str(c) for c in list(do_not_split_by)] 

107 else: 

108 do_not_split_by_list = [] 

109 

110 metrics = raw.get("metrics", []) 

111 if isinstance(metrics, str): 

112 metrics_list = [metrics] 

113 elif isinstance(metrics, Sequence): 

114 metrics_list = [str(m) for m in list(metrics)] 

115 else: 

116 metrics_list = [] 

117 

118 # Warn if 'split' is present (deprecated) 

119 if "split" in raw: 

120 warnings.warn( 

121 f"Experiment '{name}' contains deprecated 'split' component. " 

122 "It will be ignored. Use 'hold_out' instead for creating holdout sets.", 

123 DeprecationWarning, 

124 stacklevel=2, 

125 ) 

126 

127 hold_out = raw.get("hold_out", {}) 

128 hold_out_dict = dict(hold_out) if isinstance(hold_out, Mapping) else {} 

129 

130 # Parse drop_outliers: default 3.0, disable with 0.0 or false 

131 drop_outliers_raw = raw.get("drop_outliers", 3.0) 

132 drop_outliers_value: float | None = None 

133 if drop_outliers_raw is False or drop_outliers_raw == "false": 

134 drop_outliers_value = None 

135 elif isinstance(drop_outliers_raw, int | float): 

136 drop_outliers_float = float(drop_outliers_raw) 

137 if drop_outliers_float == 0.0: 

138 drop_outliers_value = None 

139 else: 

140 drop_outliers_value = drop_outliers_float 

141 elif ( 

142 isinstance(drop_outliers_raw, str) and drop_outliers_raw.lower() == "false" 

143 ): 

144 drop_outliers_value = None 

145 else: 

146 # Default to 3.0 if invalid type 

147 drop_outliers_value = 3.0 

148 

149 experiment_type = raw.get("type") 

150 experiment_type_str = None 

151 if isinstance(experiment_type, str): 

152 # Convert to lowercase and validate 

153 experiment_type_lower = experiment_type.lower() 

154 if experiment_type_lower not in VALID_EXPERIMENT_TYPES: 

155 raise ValueError( # noqa: TRY003 

156 f"Experiment '{name}' has invalid type '{experiment_type}'. " 

157 f"Valid types are: {', '.join(VALID_EXPERIMENT_TYPES)}" 

158 ) 

159 experiment_type_str = experiment_type_lower 

160 

161 self.spec = ExperimentSpec( 

162 name=name, 

163 description=description if isinstance(description, str) else None, 

164 models=models_list, 

165 dataset=dataset, 

166 target=target, 

167 features=features, 

168 do_not_split_by=do_not_split_by_list, 

169 metrics=metrics_list, 

170 hold_out=hold_out_dict, 

171 drop_outliers=drop_outliers_value, 

172 type=experiment_type_str, 

173 ) 

174 

175 # Store inferred type separately (since spec is frozen) 

176 self._inferred_type: str | None = None 

177 

178 @staticmethod 

179 def _get_experiments_section(config: YamlConfig) -> Mapping[str, Any]: 

180 experiments = config.get_data().get("experiments") 

181 if not isinstance(experiments, Mapping): 

182 raise KeyError("No 'experiments' section found in configuration") # noqa: TRY003 

183 return experiments 

184 

185 def infer_type_from_dataset(self, dataset: pd.DataFrame) -> str: 

186 """Infer experiment type from target column dtype. 

187 

188 Parameters 

189 ---------- 

190 dataset : pd.DataFrame 

191 Dataset containing the target column 

192 

193 Returns 

194 ------- 

195 str 

196 Inferred type: "regression" if target is numeric, "classification" if categorical 

197 

198 Raises 

199 ------ 

200 ValueError 

201 If inferred type is not in VALID_EXPERIMENT_TYPES 

202 """ 

203 target = self.spec.target 

204 if target not in dataset.columns: 

205 raise ValueError(f"Target column '{target}' not found in dataset") # noqa: TRY003 

206 

207 target_dtype = dataset[target].dtype 

208 if pd.api.types.is_numeric_dtype(target_dtype): 

209 inferred_type = "regression" 

210 else: 

211 inferred_type = "classification" 

212 

213 # Validate inferred type (should always be valid, but check for safety) 

214 if inferred_type not in VALID_EXPERIMENT_TYPES: 

215 raise ValueError( # noqa: TRY003 

216 f"Inferred type '{inferred_type}' is not in valid types: {', '.join(VALID_EXPERIMENT_TYPES)}" 

217 ) 

218 

219 self._inferred_type = inferred_type 

220 return inferred_type 

221 

222 def get_type(self) -> str | None: 

223 """Get experiment type, returning configured type or inferred type. 

224 

225 Returns 

226 ------- 

227 Optional[str] 

228 Experiment type: "regression", "classification", or None if not yet inferred 

229 """ 

230 if self.spec.type is not None: 

231 return self.spec.type 

232 return self._inferred_type 

233 

234 def to_dict(self) -> dict[str, Any]: 

235 return { 

236 "name": self.spec.name, 

237 "description": self.spec.description, 

238 "models": list(self.spec.models), 

239 "dataset": self.spec.dataset, 

240 "target": self.spec.target, 

241 "features": self.spec.features, 

242 "type": self.get_type(), 

243 "do_not_split_by": list(self.spec.do_not_split_by), 

244 "metrics": list(self.spec.metrics), 

245 "hold_out": dict(self.spec.hold_out), 

246 "drop_outliers": self.spec.drop_outliers, 

247 } 

248 

249 @classmethod 

250 def list_experiment_names(cls, config: YamlConfig) -> list[str]: 

251 return list(cls._get_experiments_section(config).keys()) 

252 

253 @classmethod 

254 def verify_config(cls, config: YamlConfig) -> None: 

255 """Validate experiments reference existing models, datasets, and features. 

256 

257 Only performs validation if an 'experiments' section exists. 

258 """ 

259 experiments = config.get_data().get("experiments") 

260 if experiments is None: 

261 return 

262 if not isinstance(experiments, Mapping): 

263 raise TypeError("'experiments' section must be a mapping") # noqa: TRY003 

264 

265 datasets = config.get_data().get("datasets") 

266 features = config.get_data().get("features") 

267 models = config.get_data().get("models") 

268 

269 if not isinstance(datasets, Mapping): 

270 raise TypeError("No 'datasets' section found while validating experiments") # noqa: TRY003 

271 if not isinstance(features, Mapping): 

272 raise TypeError("No 'features' section found while validating experiments") # noqa: TRY003 

273 if not isinstance(models, Mapping): 

274 raise TypeError("No 'models' section found while validating experiments") # noqa: TRY003 

275 

276 for exp_name, raw in experiments.items(): 

277 if not isinstance(raw, Mapping): 

278 raise TypeError( # noqa: TRY003 

279 f"Experiment '{exp_name}' specification must be a mapping" 

280 ) 

281 

282 # Dataset must exist 

283 ds_name = raw.get("dataset") 

284 if not isinstance(ds_name, str) or not ds_name: 

285 raise ValueError( # noqa: TRY003 

286 f"Experiment '{exp_name}' missing required 'dataset' string" 

287 ) 

288 if ds_name not in datasets: 

289 raise ValueError( # noqa: TRY003 

290 f"Experiment '{exp_name}' references unknown dataset '{ds_name}'" 

291 ) 

292 

293 # Target must be provided as a single string 

294 tgt_field = raw.get("target") 

295 if not isinstance(tgt_field, str) or not tgt_field: 

296 raise ValueError( # noqa: TRY003 

297 f"Experiment '{exp_name}' missing required 'target' string" 

298 ) 

299 

300 # Models must exist 

301 model_field = raw.get("models") 

302 model_names: list[str] 

303 if isinstance(model_field, str): 

304 model_names = [model_field] 

305 elif isinstance(model_field, Sequence): 

306 model_names = [str(m) for m in list(model_field)] 

307 else: 

308 raise TypeError( # noqa: TRY003 

309 f"Experiment '{exp_name}' missing required 'models' (str or list)" 

310 ) 

311 missing_models = [m for m in model_names if m not in models] 

312 if missing_models: 

313 raise ValueError( # noqa: TRY003 

314 f"Experiment '{exp_name}' references unknown models: {', '.join(missing_models)}" 

315 ) 

316 

317 # Features must exist 

318 feature_field = raw.get("features") 

319 if not isinstance(feature_field, str) or not feature_field: 

320 raise ValueError( # noqa: TRY003 

321 f"Experiment '{exp_name}' missing required 'features' string" 

322 ) 

323 if feature_field not in features: 

324 raise ValueError( # noqa: TRY003 

325 f"Experiment '{exp_name}' references unknown feature '{feature_field}'" 

326 ) 

327 

328 # Validate type if specified 

329 type_field = raw.get("type") 

330 if isinstance(type_field, str): 

331 type_lower = type_field.lower() 

332 if type_lower not in VALID_EXPERIMENT_TYPES: 

333 raise ValueError( # noqa: TRY003 

334 f"Experiment '{exp_name}' has invalid type '{type_field}'. " 

335 f"Valid types are: {', '.join(VALID_EXPERIMENT_TYPES)}" 

336 ) 

337 

338 

339__all__ = ["Experiment", "ExperimentSpec"]