Coverage for ml_workbench/feature.py: 14%

155 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 Iterable, Mapping, Sequence 

4from dataclasses import dataclass 

5from typing import TYPE_CHECKING, Any 

6 

7import pandas as pd 

8 

9from .dataset import Dataset 

10 

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

12 from .config import YamlConfig 

13 

14 

15@dataclass(frozen=True) 

16class FeatureSpec: 

17 name: str 

18 dataset: str 

19 numerical: list[str] 

20 categorical: list[str] 

21 column: list[str] 

22 description: str | None = None 

23 

24 

25class Feature: 

26 """Represents a feature set (group) defined in a features YAML. 

27 

28 Expected structure under ``features``: 

29 

30 features: 

31 group_name: 

32 description: "..." # optional 

33 dataset: dataset_name # required 

34 numerical: [col_a, col_b] # optional list of columns 

35 categorical: [col_c, col_d] # optional list of columns 

36 

37 Also supports "columns" or "column" forms for auto-inferred types (they are equivalent): 

38 

39 features: 

40 feature_name: 

41 dataset: dataset_name 

42 columns: [col_a, col_b, col_c] 

43 # OR 

44 column: [col_a, col_b, col_c] 

45 

46 The "columns"/"column" fields are stored as a separate attribute "column" (as a list of column names). 

47 Their types (numerical/categorical) will be inferred automatically during experiment setup, 

48 rather than being assigned here. 

49 """ 

50 

51 def __init__(self, name: str, config: YamlConfig) -> None: 

52 self.name = name 

53 self.config = config 

54 

55 features_section = self._get_features_section(config) 

56 if name not in features_section: 

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

58 

59 spec_raw = features_section[name] 

60 if not isinstance(spec_raw, Mapping): 

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

62 

63 dataset_name = spec_raw.get("dataset") 

64 description = spec_raw.get("description") 

65 

66 if not isinstance(dataset_name, str) or not dataset_name: 

67 raise ValueError( # noqa: TRY003 

68 f"Feature '{name}' is missing required 'dataset' string field" 

69 ) 

70 

71 # Accept both grouped (numerical/categorical), columns, and column schema 

72 numerical_cols: list[str] = [] 

73 categorical_cols: list[str] = [] 

74 column_cols: list[str] = [] 

75 

76 if "numerical" in spec_raw or "categorical" in spec_raw: 

77 raw_num = spec_raw.get("numerical", []) 

78 raw_cat = spec_raw.get("categorical", []) 

79 if not isinstance(raw_num, list) or not all( 

80 isinstance(x, str) for x in raw_num 

81 ): 

82 raise ValueError( # noqa: TRY003 

83 f"Feature '{name}'.numerical must be a list of strings" 

84 ) 

85 if not isinstance(raw_cat, list) or not all( 

86 isinstance(x, str) for x in raw_cat 

87 ): 

88 raise ValueError( # noqa: TRY003 

89 f"Feature '{name}'.categorical must be a list of strings" 

90 ) 

91 numerical_cols = list(raw_num) 

92 categorical_cols = list(raw_cat) 

93 elif "columns" in spec_raw or "column" in spec_raw: 

94 # Treat "columns" and "column" as equivalent 

95 column_names = spec_raw.get("columns") or spec_raw.get("column", []) 

96 if not isinstance(column_names, list) or not all( 

97 isinstance(x, str) for x in column_names 

98 ): 

99 field_name = "columns" if "columns" in spec_raw else "column" 

100 raise ValueError( # noqa: TRY003 

101 f"Feature '{name}'.{field_name} must be a list of strings" 

102 ) 

103 # Store in column_cols for type inference later 

104 # These will be re-evaluated based on the dataset dtypes 

105 column_cols = list(column_names) 

106 else: 

107 raise ValueError( # noqa: TRY003 

108 f"Feature '{name}' must define either 'numerical'/'categorical', 'columns', or 'column'" 

109 ) 

110 

111 self.spec = FeatureSpec( 

112 name=name, 

113 dataset=dataset_name, 

114 numerical=numerical_cols, 

115 categorical=categorical_cols, 

116 column=column_cols, 

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

118 ) 

119 

120 # Validate referenced dataset exists 

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

122 if not (isinstance(datasets, Mapping) and self.spec.dataset in datasets): 

123 raise ValueError( # noqa: TRY003 

124 f"Feature '{name}' references unknown dataset '{self.spec.dataset}'" 

125 ) 

126 

127 @staticmethod 

128 def _get_features_section(config: YamlConfig) -> Mapping[str, Any]: 

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

130 if not isinstance(features, Mapping): 

131 raise KeyError("No 'features' section found in configuration") # noqa: TRY003 

132 return features 

133 

134 def get_series(self, column: str, *, index: str | None = None) -> pd.Series: 

135 """Materialize a single column from this feature set as a pandas Series. 

136 

137 The ``column`` must be listed under this feature set's numerical, 

138 categorical, or column lists. 

139 """ 

140 if ( 

141 column not in self.spec.numerical 

142 and column not in self.spec.categorical 

143 and column not in self.spec.column 

144 ): 

145 raise KeyError( # noqa: TRY003 

146 f"Column '{column}' is not declared in feature set '{self.name}'" 

147 ) 

148 

149 dataset = Dataset(self.spec.dataset, self.config) 

150 df = dataset.read_pandas() 

151 if index is not None and index in df.columns: 

152 df = df.set_index(index) 

153 if column not in df.columns: 

154 raise KeyError( # noqa: TRY003 

155 f"Column '{column}' not found in dataset '{self.spec.dataset}'" 

156 ) 

157 series = df[column].copy() 

158 series.name = f"{self.name}.{column}" 

159 return series 

160 

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

162 return { 

163 "name": self.spec.name, 

164 "dataset": self.spec.dataset, 

165 "numerical": list(self.spec.numerical), 

166 "categorical": list(self.spec.categorical), 

167 "column": list(self.spec.column), 

168 "description": self.spec.description, 

169 } 

170 

171 def get_columns_by_type(self) -> dict[str, list[str]]: 

172 return { 

173 "numerical": list(self.spec.numerical), 

174 "categorical": list(self.spec.categorical), 

175 "column": list(self.spec.column), 

176 } 

177 

178 @classmethod 

179 def list_feature_names(cls, config: YamlConfig) -> list[str]: 

180 features = cls._get_features_section(config) 

181 return list(features.keys()) 

182 

183 @classmethod 

184 def load_all(cls, config: YamlConfig) -> dict[str, Feature]: 

185 names = cls.list_feature_names(config) 

186 return {name: cls(name, config) for name in names} 

187 

188 @classmethod 

189 def to_dataframe( 

190 cls, 

191 config: YamlConfig, 

192 *, 

193 feature_sets: Iterable[str] | None = None, 

194 include_types: Sequence[str] | None = None, 

195 index: str | None = None, 

196 ) -> pd.DataFrame: 

197 """Materialize multiple feature sets into a pandas DataFrame. 

198 

199 Parameters 

200 ---------- 

201 feature_sets: optional iterable of feature set names to include. If None, all are used. 

202 include_types: optional list among ["numerical", "categorical", "column"] to filter columns. 

203 index: optional column name used as index before joining. 

204 """ 

205 selected_sets = list(feature_sets or cls.list_feature_names(config)) 

206 type_filter = set(include_types or ["numerical", "categorical"]) 

207 

208 series_list: list[pd.Series] = [] 

209 for fs_name in selected_sets: 

210 fs = cls(fs_name, config) 

211 columns: list[str] = [] 

212 if "numerical" in type_filter: 

213 columns.extend(fs.spec.numerical) 

214 if "categorical" in type_filter: 

215 columns.extend(fs.spec.categorical) 

216 if "column" in type_filter: 

217 columns.extend(fs.spec.column) 

218 for col in columns: 

219 series_list.append(fs.get_series(col, index=index)) 

220 

221 if not series_list: 

222 return pd.DataFrame() 

223 

224 df = series_list[0].to_frame() 

225 for s in series_list[1:]: 

226 df = df.join(s, how="outer") 

227 return df 

228 

229 @classmethod 

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

231 """Validate that all features are well-formed and reference known datasets. 

232 

233 Checks 

234 ------ 

235 - features section exists if referenced and is a mapping 

236 - each feature has a string "dataset" and the dataset exists in config.datasets 

237 - either numerical/categorical lists (lists of strings) are present, or a legacy 

238 single "column" (string) is provided 

239 - no column appears in both numerical and categorical within the same feature 

240 - at least one column is declared per feature 

241 """ 

242 

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

244 if features is None: 

245 return # Nothing to verify 

246 if not isinstance(features, Mapping): 

247 raise TypeError("'features' section must be a mapping") # noqa: TRY003 

248 

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

250 if not isinstance(datasets, Mapping): 

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

252 

253 for feature_name, raw in features.items(): 

254 if not isinstance(raw, Mapping): 

255 raise TypeError( # noqa: TRY003 

256 f"Feature '{feature_name}' specification must be a mapping" 

257 ) 

258 

259 dataset_name = raw.get("dataset") 

260 if not isinstance(dataset_name, str) or not dataset_name: 

261 raise ValueError( # noqa: TRY003 

262 f"Feature '{feature_name}' is missing required 'dataset' string field" 

263 ) 

264 if dataset_name not in datasets: 

265 raise ValueError( # noqa: TRY003 

266 f"Feature '{feature_name}' references unknown dataset '{dataset_name}'" 

267 ) 

268 

269 has_grouped = ("numerical" in raw) or ("categorical" in raw) 

270 has_columns_or_column = "columns" in raw or "column" in raw 

271 declared_columns: list[str] = [] 

272 if has_grouped: 

273 for key in ("numerical", "categorical"): 

274 val = raw.get(key, []) 

275 if val is None: 

276 val = [] 

277 if not isinstance(val, list) or not all( 

278 isinstance(x, str) for x in val 

279 ): 

280 raise ValueError( # noqa: TRY003 

281 f"Feature '{feature_name}'.{key} must be a list of strings" 

282 ) 

283 num_list = list(raw.get("numerical", []) or []) 

284 cat_list = list(raw.get("categorical", []) or []) 

285 # Check duplicates across types 

286 overlap = set(num_list).intersection(cat_list) 

287 if overlap: 

288 dup = ", ".join(sorted(overlap)) 

289 raise ValueError( # noqa: TRY003 

290 f"Feature '{feature_name}' declares columns in both numerical and categorical: {dup}" 

291 ) 

292 declared_columns.extend(num_list) 

293 declared_columns.extend(cat_list) 

294 # '__all__' is only allowed in the 'columns'/'column' form and must be alone 

295 if "__all__" in num_list or "__all__" in cat_list: 

296 raise ValueError( # noqa: TRY003 

297 f"Feature '{feature_name}' cannot use '__all__' inside 'numerical' or 'categorical'; use 'columns: [__all__]' or 'column: [__all__]'" 

298 ) 

299 elif has_columns_or_column: 

300 # Treat "columns" and "column" as equivalent 

301 column_names = raw.get("columns") or raw.get("column", []) 

302 field_name = "columns" if "columns" in raw else "column" 

303 if not isinstance(column_names, list) or not all( 

304 isinstance(x, str) for x in column_names 

305 ): 

306 raise ValueError( # noqa: TRY003 

307 f"Feature '{feature_name}'.{field_name} must be a list of strings" 

308 ) 

309 # If '__all__' is used, it must be the only entry 

310 if "__all__" in column_names and len(column_names) != 1: 

311 raise ValueError( # noqa: TRY003 

312 f"Feature '{feature_name}' uses '__all__' alongside other columns; '__all__' must be alone" 

313 ) 

314 declared_columns.extend(column_names) 

315 else: 

316 raise ValueError( # noqa: TRY003 

317 f"Feature '{feature_name}' must define either 'numerical'/'categorical', 'columns', or 'column'" 

318 ) 

319 if not declared_columns: 

320 raise ValueError(f"Feature '{feature_name}' declares no columns") # noqa: TRY003 

321 # '__all__' is allowed only when it is the single entry in the 'columns' or 'column' list. 

322 # The grouped form is already rejected above. 

323 

324 

325__all__ = ["Feature", "FeatureSpec"]