upgini 1.2.166a4253.dev2__tar.gz → 1.2.167a4220.dev1__tar.gz

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 (92) hide show
  1. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev1}/PKG-INFO +1 -1
  2. upgini-1.2.167a4220.dev1/src/upgini/__about__.py +1 -0
  3. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev1}/src/upgini/features_enricher.py +131 -27
  4. upgini-1.2.167a4220.dev1/src/upgini/report/__init__.py +5 -0
  5. upgini-1.2.167a4220.dev1/src/upgini/report/assemble.py +85 -0
  6. upgini-1.2.167a4220.dev1/src/upgini/report/data.py +72 -0
  7. upgini-1.2.167a4220.dev1/src/upgini/report/html.py +281 -0
  8. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev1}/src/upgini/utils/display_utils.py +31 -0
  9. upgini-1.2.166a4253.dev2/src/upgini/__about__.py +0 -1
  10. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev1}/.gitignore +0 -0
  11. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev1}/LICENSE +0 -0
  12. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev1}/README.md +0 -0
  13. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev1}/pyproject.toml +0 -0
  14. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev1}/src/upgini/__init__.py +0 -0
  15. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev1}/src/upgini/ads.py +0 -0
  16. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev1}/src/upgini/ads_management/__init__.py +0 -0
  17. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev1}/src/upgini/ads_management/ads_manager.py +0 -0
  18. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev1}/src/upgini/autofe/__init__.py +0 -0
  19. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev1}/src/upgini/autofe/all_operators.py +0 -0
  20. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev1}/src/upgini/autofe/binary.py +0 -0
  21. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev1}/src/upgini/autofe/date.py +0 -0
  22. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev1}/src/upgini/autofe/feature.py +0 -0
  23. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev1}/src/upgini/autofe/groupby.py +0 -0
  24. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev1}/src/upgini/autofe/operand.py +0 -0
  25. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev1}/src/upgini/autofe/operator.py +0 -0
  26. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev1}/src/upgini/autofe/timeseries/__init__.py +0 -0
  27. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev1}/src/upgini/autofe/timeseries/base.py +0 -0
  28. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev1}/src/upgini/autofe/timeseries/cross.py +0 -0
  29. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev1}/src/upgini/autofe/timeseries/delta.py +0 -0
  30. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev1}/src/upgini/autofe/timeseries/lag.py +0 -0
  31. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev1}/src/upgini/autofe/timeseries/numpy_kernels.py +0 -0
  32. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev1}/src/upgini/autofe/timeseries/roll.py +0 -0
  33. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev1}/src/upgini/autofe/timeseries/trend.py +0 -0
  34. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev1}/src/upgini/autofe/timeseries/volatility.py +0 -0
  35. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev1}/src/upgini/autofe/unary.py +0 -0
  36. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev1}/src/upgini/autofe/utils.py +0 -0
  37. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev1}/src/upgini/autofe/vector.py +0 -0
  38. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev1}/src/upgini/data_source/__init__.py +0 -0
  39. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev1}/src/upgini/data_source/data_source_publisher.py +0 -0
  40. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev1}/src/upgini/dataset.py +0 -0
  41. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev1}/src/upgini/errors.py +0 -0
  42. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev1}/src/upgini/http.py +0 -0
  43. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev1}/src/upgini/mdc/__init__.py +0 -0
  44. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev1}/src/upgini/mdc/context.py +0 -0
  45. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev1}/src/upgini/metadata.py +0 -0
  46. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev1}/src/upgini/metrics.py +0 -0
  47. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev1}/src/upgini/normalizer/__init__.py +0 -0
  48. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev1}/src/upgini/normalizer/normalize_utils.py +0 -0
  49. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev1}/src/upgini/resource_bundle/__init__.py +0 -0
  50. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev1}/src/upgini/resource_bundle/exceptions.py +0 -0
  51. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev1}/src/upgini/resource_bundle/strings.properties +0 -0
  52. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev1}/src/upgini/resource_bundle/strings_widget.properties +0 -0
  53. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev1}/src/upgini/sampler/__init__.py +0 -0
  54. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev1}/src/upgini/sampler/base.py +0 -0
  55. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev1}/src/upgini/sampler/random_under_sampler.py +0 -0
  56. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev1}/src/upgini/sampler/utils.py +0 -0
  57. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev1}/src/upgini/search_task.py +0 -0
  58. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev1}/src/upgini/spinner.py +0 -0
  59. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev1}/src/upgini/utils/Roboto-Regular.ttf +0 -0
  60. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev1}/src/upgini/utils/__init__.py +0 -0
  61. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev1}/src/upgini/utils/base_search_key_detector.py +0 -0
  62. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev1}/src/upgini/utils/blocked_time_series.py +0 -0
  63. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev1}/src/upgini/utils/config.py +0 -0
  64. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev1}/src/upgini/utils/country_utils.py +0 -0
  65. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev1}/src/upgini/utils/cpu_utils.py +0 -0
  66. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev1}/src/upgini/utils/custom_loss_utils.py +0 -0
  67. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev1}/src/upgini/utils/cv_utils.py +0 -0
  68. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev1}/src/upgini/utils/datetime_utils.py +0 -0
  69. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev1}/src/upgini/utils/deduplicate_utils.py +0 -0
  70. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev1}/src/upgini/utils/email_utils.py +0 -0
  71. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev1}/src/upgini/utils/fallback_progress_bar.py +0 -0
  72. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev1}/src/upgini/utils/feature_info.py +0 -0
  73. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev1}/src/upgini/utils/features_validator.py +0 -0
  74. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev1}/src/upgini/utils/format.py +0 -0
  75. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev1}/src/upgini/utils/hash_utils.py +0 -0
  76. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev1}/src/upgini/utils/ip_utils.py +0 -0
  77. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev1}/src/upgini/utils/mstats.py +0 -0
  78. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev1}/src/upgini/utils/one_hot_encoder.py +0 -0
  79. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev1}/src/upgini/utils/phone_utils.py +0 -0
  80. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev1}/src/upgini/utils/postal_code_utils.py +0 -0
  81. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev1}/src/upgini/utils/progress_bar.py +0 -0
  82. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev1}/src/upgini/utils/psi.py +0 -0
  83. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev1}/src/upgini/utils/pyarrow_utils.py +0 -0
  84. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev1}/src/upgini/utils/sample_utils.py +0 -0
  85. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev1}/src/upgini/utils/search_key_derivations.py +0 -0
  86. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev1}/src/upgini/utils/sklearn_ext.py +0 -0
  87. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev1}/src/upgini/utils/sort.py +0 -0
  88. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev1}/src/upgini/utils/target_utils.py +0 -0
  89. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev1}/src/upgini/utils/track_info.py +0 -0
  90. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev1}/src/upgini/utils/ts_utils.py +0 -0
  91. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev1}/src/upgini/utils/warning_counter.py +0 -0
  92. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev1}/src/upgini/version_validator.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: upgini
3
- Version: 1.2.166a4253.dev2
3
+ Version: 1.2.167a4220.dev1
4
4
  Summary: Intelligent data search & enrichment for Machine Learning
5
5
  Project-URL: Bug Reports, https://github.com/upgini/upgini/issues
6
6
  Project-URL: Homepage, https://upgini.com/
@@ -0,0 +1 @@
1
+ __version__ = "1.2.167.a4220.dev1"
@@ -92,10 +92,14 @@ from upgini.utils.deduplicate_utils import (
92
92
  clean_full_duplicates,
93
93
  remove_fintech_duplicates,
94
94
  )
95
+ from upgini.report.assemble import assemble_report_data
96
+ from upgini.report.html import generate_html_report
95
97
  from upgini.utils.display_utils import (
96
98
  display_html_dataframe,
97
99
  do_without_pandas_limits,
100
+ ipython_available,
98
101
  prepare_and_show_report,
102
+ show_button_open_report,
99
103
  show_request_quote_button,
100
104
  )
101
105
  from upgini.utils.email_utils import (
@@ -175,6 +179,9 @@ class FeaturesEnricher(TransformerMixin):
175
179
 
176
180
  shared_datasets: list of str, optional (default=None)
177
181
  list of private shared dataset ids for custom search
182
+
183
+ reports_path: str, optional (default=None)
184
+ Directory for the HTML search report. Defaults to a `reports` folder in the current working directory.
178
185
  """
179
186
 
180
187
  TARGET_NAME = "target"
@@ -252,6 +259,7 @@ class FeaturesEnricher(TransformerMixin):
252
259
  sample_config: SampleConfig | None = None,
253
260
  print_trace_id: bool = False,
254
261
  print_loaded_report: bool = True,
262
+ reports_path: str | None = None,
255
263
  **kwargs,
256
264
  ):
257
265
  self.bundle = get_custom_bundle(custom_bundle_config)
@@ -310,6 +318,7 @@ class FeaturesEnricher(TransformerMixin):
310
318
  self.relevant_data_sources: pd.DataFrame = self.EMPTY_DATA_SOURCES
311
319
  self._relevant_data_sources_wo_links: pd.DataFrame = self.EMPTY_DATA_SOURCES
312
320
  self.metrics: pd.DataFrame | None = None
321
+ self.metrics_metric_name: str | None = None
313
322
  self.feature_names_ = []
314
323
  self.external_source_feature_names = []
315
324
  self.feature_importances_ = []
@@ -319,6 +328,9 @@ class FeaturesEnricher(TransformerMixin):
319
328
  self.disable_force_downsampling = disable_force_downsampling
320
329
  self.print_trace_id = print_trace_id
321
330
  self.add_info: AddInfo | None = None
331
+ self.reports_path = reports_path
332
+ self.search_duration_seconds: float | None = None
333
+ self.report_file_path: str | None = None
322
334
 
323
335
  if search_id:
324
336
  search_task = SearchTask(search_id, rest_client=self.rest_client, logger=self.logger)
@@ -1138,18 +1150,9 @@ class FeaturesEnricher(TransformerMixin):
1138
1150
  uplift_perc = None
1139
1151
  enriched_estimator = None
1140
1152
  ensemble_score_column = self._get_ensemble_score_column(fitting_X, fitting_enriched_X)
1141
- if set(fitting_X.columns) != set(fitting_enriched_X.columns):
1142
- if self.baseline_score_column is not None and ensemble_score_column is not None:
1143
- self.logger.info(
1144
- f"Only ensemble model returned; calculate enriched {metric} via roc_auc "
1145
- f"on ensemble score column: {ensemble_score_column}"
1146
- )
1147
- else:
1148
- self.logger.info(
1149
- f"Calculate enriched {metric} on train combined "
1150
- f"features: {fitting_enriched_X.columns.to_list()}"
1151
- )
1152
- enriched_estimator = EstimatorWrapper.create(
1153
+
1154
+ def evaluate_enriched(score_column, description: str):
1155
+ wrapper = EstimatorWrapper.create(
1153
1156
  estimator,
1154
1157
  self.logger,
1155
1158
  model_task_type,
@@ -1161,22 +1164,48 @@ class FeaturesEnricher(TransformerMixin):
1161
1164
  text_features=text_features,
1162
1165
  has_time=has_time,
1163
1166
  )
1164
- enriched_cv_result = enriched_estimator.cross_val_predict(
1165
- fitting_enriched_X, enriched_y_sorted, ensemble_score_column
1167
+ cv_result = wrapper.cross_val_predict(
1168
+ fitting_enriched_X, enriched_y_sorted, score_column
1166
1169
  )
1167
- enriched_metric = enriched_cv_result.get_display_metric()
1168
- updating_shaps = enriched_cv_result.shap_values
1169
-
1170
- if enriched_metric is None:
1171
- self.logger.warning(
1172
- f"Enriched {metric} on train combined features is None (maybe all features was removed)"
1173
- )
1174
- enriched_estimator = None
1175
- else:
1176
- self.logger.info(f"Enriched {metric} on train combined features: {enriched_metric}")
1177
- if baseline_metric is not None and enriched_metric is not None:
1178
- uplift = (enriched_cv_result.metric - baseline_cv_result.metric) * multiplier
1179
- uplift_perc = uplift / abs(baseline_cv_result.metric) * 100
1170
+ display_metric = cv_result.get_display_metric()
1171
+ if display_metric is None:
1172
+ self.logger.warning(f"Enriched {metric} {description} is None")
1173
+ return None, None, cv_result.shap_values, None, None
1174
+ self.logger.info(f"Enriched {metric} {description}: {display_metric}")
1175
+ sample_uplift = None
1176
+ sample_uplift_perc = None
1177
+ if baseline_metric is not None:
1178
+ sample_uplift = (cv_result.metric - baseline_cv_result.metric) * multiplier
1179
+ sample_uplift_perc = sample_uplift / abs(baseline_cv_result.metric) * 100
1180
+ return wrapper, display_metric, cv_result.shap_values, sample_uplift, sample_uplift_perc
1181
+
1182
+ if ensemble_score_column is not None:
1183
+ self.logger.info(
1184
+ f"Only ensemble model returned; calculate enriched {metric} via roc_auc "
1185
+ f"on ensemble score column: {ensemble_score_column}"
1186
+ )
1187
+ (
1188
+ enriched_estimator,
1189
+ enriched_metric,
1190
+ updating_shaps,
1191
+ uplift,
1192
+ uplift_perc,
1193
+ ) = evaluate_enriched(
1194
+ ensemble_score_column,
1195
+ f"on ensemble score column {ensemble_score_column}",
1196
+ )
1197
+ elif set(fitting_X.columns) != set(fitting_enriched_X.columns):
1198
+ self.logger.info(
1199
+ f"Calculate enriched {metric} on train combined "
1200
+ f"features: {fitting_enriched_X.columns.to_list()}"
1201
+ )
1202
+ (
1203
+ enriched_estimator,
1204
+ enriched_metric,
1205
+ updating_shaps,
1206
+ uplift,
1207
+ uplift_perc,
1208
+ ) = evaluate_enriched(None, "on train combined features")
1180
1209
 
1181
1210
  train_metrics = {
1182
1211
  self.bundle.get("quality_metrics_segment_header"): self.bundle.get(
@@ -1323,6 +1352,7 @@ class FeaturesEnricher(TransformerMixin):
1323
1352
  elif uplift_col in metrics_df.columns and (metrics_df[uplift_col] < 0).any():
1324
1353
  self.logger.warning("Uplift is negative")
1325
1354
 
1355
+ self.metrics_metric_name = metric
1326
1356
  return metrics_df
1327
1357
  except Exception as e:
1328
1358
  error_message = "Failed to calculate metrics" + (
@@ -2224,6 +2254,69 @@ class FeaturesEnricher(TransformerMixin):
2224
2254
  column = new_external[0]
2225
2255
  return column if self._is_ensemble_feature(column) else None
2226
2256
 
2257
+ def _has_single_ensemble_score(self) -> bool:
2258
+ return self._get_single_ensemble_score_name() is not None
2259
+
2260
+ def _get_single_ensemble_score_name(self) -> str | None:
2261
+ selected = list(self.feature_names_ or [])
2262
+ ensemble = [name for name in selected if self._is_ensemble_feature(name)]
2263
+ if len(ensemble) != 1:
2264
+ return None
2265
+ etalon_columns = set()
2266
+ if self._search_task is not None:
2267
+ etalon_columns = {
2268
+ meta.name
2269
+ for meta in (self._search_task.get_all_features_metadata_v2() or [])
2270
+ if meta.source == "etalon"
2271
+ }
2272
+ extras = [name for name in selected if name != ensemble[0] and name not in etalon_columns]
2273
+ if extras:
2274
+ return None
2275
+ return ensemble[0]
2276
+
2277
+ def _reports_dir(self) -> Path:
2278
+ return Path(self.reports_path) if self.reports_path else Path.cwd() / "reports"
2279
+
2280
+ def _reference_rows(self) -> int | None:
2281
+ if self.X is None:
2282
+ return None
2283
+ rows = _num_samples(self.X)
2284
+ for eval_pair in self.eval_set or []:
2285
+ rows += _num_samples(eval_pair[0])
2286
+ return rows
2287
+
2288
+ def _report_sample_names(self) -> list[str]:
2289
+ samples = [self.bundle.get("quality_metrics_train_segment")]
2290
+ for idx, _ in enumerate(self.eval_set or []):
2291
+ samples.append(self.bundle.get("quality_metrics_eval_segment").format(idx + 1))
2292
+ return samples
2293
+
2294
+ def _assemble_report_data(self):
2295
+ search_id = self._search_task.search_task_id if self._search_task is not None else (self.search_id or "")
2296
+ return assemble_report_data(
2297
+ search_id=search_id,
2298
+ search_keys=[str(sk) for sk in self.search_keys.values()],
2299
+ search_duration_seconds=self.search_duration_seconds,
2300
+ reference_rows=self._reference_rows(),
2301
+ samples=self._report_sample_names(),
2302
+ metrics_df=self.metrics,
2303
+ metric_name=self.metrics_metric_name,
2304
+ bundle=self.bundle,
2305
+ )
2306
+
2307
+ def _write_score_report(self) -> str | None:
2308
+ if self._search_task is None or not self._has_single_ensemble_score():
2309
+ return None
2310
+ report_data = self._assemble_report_data()
2311
+ html = generate_html_report(report_data)
2312
+ reports_dir = self._reports_dir()
2313
+ reports_dir.mkdir(parents=True, exist_ok=True)
2314
+ path = reports_dir / f"upgini-report-{report_data.metadata.search_id}.html"
2315
+ path.write_text(html, encoding="utf-8")
2316
+ self.report_file_path = str(path)
2317
+ self.logger.info(f"Score report saved to {path}")
2318
+ return self.report_file_path
2319
+
2227
2320
  def _is_ensemble_feature(self, column_name: str) -> bool:
2228
2321
  renaming = self.fit_columns_renaming or {}
2229
2322
  aliases = self._column_name_aliases([column_name], renaming)
@@ -4126,6 +4219,7 @@ if response.status_code == 200:
4126
4219
  self._fit_match_search_keys = None
4127
4220
  self.__cached_sampled_datasets = dict()
4128
4221
  self.metrics = None
4222
+ self.metrics_metric_name = None
4129
4223
  self.fit_columns_renaming = None
4130
4224
  self.fit_dropped_features = set()
4131
4225
  self.fit_generated_features = []
@@ -4536,6 +4630,7 @@ if response.status_code == 200:
4536
4630
 
4537
4631
  self._search_task.poll_result(self._get_trace_id(), quiet=True)
4538
4632
  self.__persist_transform_match_hashes()
4633
+ self.search_duration_seconds = time.time() - start_time
4539
4634
 
4540
4635
  seconds_left = time.time() - start_time
4541
4636
  progress = SearchProgress(97.0, ProgressStage.GENERATING_REPORT, seconds_left)
@@ -5942,6 +6037,15 @@ if response.status_code == 200:
5942
6037
 
5943
6038
  def __show_report_button(self, display_id: str | None = None, display_handle=None):
5944
6039
  try:
6040
+ report_path = self._write_score_report()
6041
+ if report_path is not None:
6042
+ if not ipython_available():
6043
+ print(f"Full report saved to {report_path}")
6044
+ return
6045
+ return show_button_open_report(
6046
+ report_path, display_id=display_id, display_handle=display_handle
6047
+ )
6048
+
5945
6049
  eval_sets_drift_df = self._get_eval_sets_drift_summary()
5946
6050
 
5947
6051
  return prepare_and_show_report(
@@ -0,0 +1,5 @@
1
+ from upgini.report.assemble import assemble_report_data, format_search_duration
2
+ from upgini.report.data import ReportData
3
+ from upgini.report.html import generate_html_report
4
+
5
+ __all__ = ["ReportData", "assemble_report_data", "format_search_duration", "generate_html_report"]
@@ -0,0 +1,85 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime, timezone
4
+ from typing import Optional
5
+
6
+ import pandas as pd
7
+
8
+ from upgini.__about__ import __version__
9
+ from upgini.report.data import QualitySample, ReportData, ReportMetadata
10
+ from upgini.resource_bundle import ResourceBundle
11
+
12
+
13
+ def format_search_duration(seconds: Optional[float]) -> Optional[str]:
14
+ if seconds is None:
15
+ return None
16
+ total = max(0, int(round(seconds)))
17
+ hours, remainder = divmod(total, 3600)
18
+ minutes, secs = divmod(remainder, 60)
19
+ if hours:
20
+ return f"{hours} h {minutes} min {secs} sec"
21
+ if minutes:
22
+ return f"{minutes} min {secs} sec"
23
+ return f"{secs} sec"
24
+
25
+
26
+ def assemble_report_data(
27
+ *,
28
+ search_id: str,
29
+ search_keys: list[str],
30
+ generated_at: Optional[datetime] = None,
31
+ pylib_version: Optional[str] = None,
32
+ search_duration_seconds: Optional[float] = None,
33
+ reference_rows: Optional[int] = None,
34
+ samples: Optional[list[str]] = None,
35
+ metrics_df: Optional[pd.DataFrame] = None,
36
+ metric_name: Optional[str] = None,
37
+ bundle: ResourceBundle,
38
+ ) -> ReportData:
39
+ generated_at = generated_at or datetime.now(timezone.utc)
40
+ return ReportData(
41
+ metadata=ReportMetadata(
42
+ search_id=search_id,
43
+ generated_at=generated_at.strftime("%Y-%m-%d %H:%M UTC"),
44
+ pylib_version=pylib_version or __version__,
45
+ search_keys=list(search_keys or []),
46
+ search_duration=format_search_duration(search_duration_seconds),
47
+ reference_rows=reference_rows,
48
+ samples=list(samples or []),
49
+ ),
50
+ quality_by_sample=_quality_samples(metrics_df, metric_name, bundle),
51
+ )
52
+
53
+
54
+ def _quality_samples(
55
+ metrics_df: Optional[pd.DataFrame], metric_name: Optional[str], bundle: ResourceBundle
56
+ ) -> list[QualitySample]:
57
+ if metrics_df is None or metrics_df.empty:
58
+ return []
59
+ segment_col = bundle.get("quality_metrics_segment_header")
60
+ uplift_col = bundle.get("quality_metrics_uplift_header")
61
+ uplift_pct_col = bundle.get("quality_metrics_uplift_perc_header")
62
+ baseline_col = bundle.get("quality_metrics_baseline_header").format(metric_name) if metric_name else None
63
+ enriched_col = bundle.get("quality_metrics_enriched_header").format(metric_name) if metric_name else None
64
+
65
+ samples: list[QualitySample] = []
66
+ for _, row in metrics_df.iterrows():
67
+ samples.append(
68
+ QualitySample(
69
+ evaluation_scope=(_as_str(row[segment_col]) or "") if segment_col in metrics_df.columns else "",
70
+ metric=metric_name or "",
71
+ baseline=_as_str(row[baseline_col]) if baseline_col in metrics_df.columns else None,
72
+ enriched=_as_str(row[enriched_col]) if enriched_col in metrics_df.columns else None,
73
+ uplift=_as_float(row[uplift_col]) if uplift_col in metrics_df.columns else None,
74
+ relative_uplift=_as_str(row[uplift_pct_col]) if uplift_pct_col in metrics_df.columns else None,
75
+ )
76
+ )
77
+ return samples
78
+
79
+
80
+ def _as_str(value) -> Optional[str]:
81
+ return None if pd.isna(value) else str(value)
82
+
83
+
84
+ def _as_float(value) -> Optional[float]:
85
+ return None if pd.isna(value) else float(value)
@@ -0,0 +1,72 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Optional
5
+
6
+
7
+ @dataclass
8
+ class ReportMetadata:
9
+ search_id: str
10
+ generated_at: str
11
+ pylib_version: str
12
+ search_keys: list[str] = field(default_factory=list)
13
+ search_duration: Optional[str] = None
14
+ reference_rows: Optional[int] = None
15
+ samples: list[str] = field(default_factory=list)
16
+
17
+
18
+ @dataclass
19
+ class QualitySample:
20
+ evaluation_scope: str
21
+ metric: str
22
+ baseline: Optional[str] = None
23
+ enriched: Optional[str] = None
24
+ uplift: Optional[float] = None
25
+ relative_uplift: Optional[str] = None
26
+
27
+
28
+ @dataclass
29
+ class FeatureRow:
30
+ name: str
31
+ shap: Optional[float] = None
32
+ psi: Optional[float] = None
33
+ drift: Optional[float] = None
34
+ coverage: Optional[float] = None
35
+ provider: str = ""
36
+ source: str = ""
37
+ stability_status: Optional[str] = None
38
+
39
+
40
+ @dataclass
41
+ class SourceRow:
42
+ provider: str
43
+ source: str
44
+ shap_sum: Optional[float] = None
45
+ feature_count: Optional[int] = None
46
+
47
+
48
+ @dataclass
49
+ class SearchResultsSummary:
50
+ relevant_features: Optional[int] = None
51
+ model_features: Optional[int] = None
52
+ data_sources: Optional[int] = None
53
+ stable_features_share: Optional[float] = None
54
+
55
+
56
+ @dataclass
57
+ class ModelFeatureShap:
58
+ rank: int
59
+ feature: str
60
+ provider: str = ""
61
+ mean_abs_shap: Optional[float] = None
62
+
63
+
64
+ @dataclass
65
+ class ReportData:
66
+ metadata: ReportMetadata
67
+ quality_by_sample: list[QualitySample] = field(default_factory=list)
68
+ summary: SearchResultsSummary = field(default_factory=SearchResultsSummary)
69
+ features: list[FeatureRow] = field(default_factory=list)
70
+ sources: list[SourceRow] = field(default_factory=list)
71
+ autofe: list[dict[str, str]] = field(default_factory=list)
72
+ model_feature_shap: list[ModelFeatureShap] = field(default_factory=list)
@@ -0,0 +1,281 @@
1
+ from __future__ import annotations
2
+
3
+ from html import escape
4
+ from typing import Optional
5
+
6
+ from upgini.report.data import FeatureRow, QualitySample, ReportData, SearchResultsSummary, SourceRow
7
+
8
+
9
+ def generate_html_report(data: ReportData) -> str:
10
+ meta = data.metadata
11
+ keys = ", ".join(meta.search_keys) if meta.search_keys else "—"
12
+ samples = ", ".join(meta.samples) if meta.samples else "—"
13
+ duration = meta.search_duration or "—"
14
+ rows = f"{meta.reference_rows:,}" if meta.reference_rows is not None else "—"
15
+ return f"""<!DOCTYPE html>
16
+ <html lang="en">
17
+ <head>
18
+ <meta charset="UTF-8" />
19
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
20
+ <title>Upgini search report {escape(meta.search_id)}</title>
21
+ <style>
22
+ :root {{
23
+ --bg: #f4f1ea;
24
+ --ink: #1c1917;
25
+ --muted: #57534e;
26
+ --card: #fffdf8;
27
+ --line: #e7e0d4;
28
+ --accent: #0f766e;
29
+ --accent-ink: #134e4a;
30
+ --chip: #ecfdf5;
31
+ }}
32
+ * {{ box-sizing: border-box; }}
33
+ body {{
34
+ margin: 0;
35
+ color: var(--ink);
36
+ background: var(--bg);
37
+ font-family: "Source Sans 3", "Segoe UI", Helvetica, Arial, sans-serif;
38
+ line-height: 1.45;
39
+ }}
40
+ header {{
41
+ background: #1c1917;
42
+ color: #fafaf9;
43
+ padding: 28px 32px 24px;
44
+ }}
45
+ header .kicker {{
46
+ letter-spacing: 0.12em;
47
+ text-transform: uppercase;
48
+ font-size: 12px;
49
+ color: #a8a29e;
50
+ margin-bottom: 8px;
51
+ }}
52
+ h1 {{ margin: 0 0 12px; font-size: 28px; font-weight: 600; }}
53
+ .chips {{ display: flex; flex-wrap: wrap; gap: 8px; }}
54
+ .chip {{
55
+ background: #292524;
56
+ color: #e7e5e4;
57
+ border-radius: 999px;
58
+ padding: 4px 10px;
59
+ font-size: 13px;
60
+ }}
61
+ main {{ max-width: 1080px; margin: 0 auto; padding: 24px 20px 48px; }}
62
+ section {{
63
+ background: var(--card);
64
+ border: 1px solid var(--line);
65
+ border-radius: 12px;
66
+ padding: 20px 22px;
67
+ margin-bottom: 16px;
68
+ }}
69
+ h2 {{ margin: 0 0 14px; font-size: 18px; }}
70
+ .meta-grid, .cards {{
71
+ display: grid;
72
+ grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
73
+ gap: 12px;
74
+ }}
75
+ .meta-item .label, .card .label {{
76
+ color: var(--muted);
77
+ font-size: 12px;
78
+ text-transform: uppercase;
79
+ letter-spacing: 0.04em;
80
+ }}
81
+ .meta-item .value, .card .value {{
82
+ font-size: 20px;
83
+ font-weight: 600;
84
+ margin-top: 4px;
85
+ }}
86
+ table {{
87
+ width: 100%;
88
+ border-collapse: collapse;
89
+ font-size: 14px;
90
+ }}
91
+ th, td {{
92
+ text-align: left;
93
+ padding: 8px 10px;
94
+ border-bottom: 1px solid var(--line);
95
+ vertical-align: top;
96
+ }}
97
+ th {{ color: var(--muted); font-weight: 600; font-size: 12px; text-transform: uppercase; }}
98
+ .num {{ text-align: right; font-variant-numeric: tabular-nums; }}
99
+ .uplift {{ color: var(--accent-ink); font-weight: 600; }}
100
+ .empty {{ color: var(--muted); }}
101
+ </style>
102
+ </head>
103
+ <body>
104
+ <header>
105
+ <div class="kicker">Upgini search report</div>
106
+ <h1>FIT completed successfully</h1>
107
+ <div class="chips">
108
+ <span class="chip">search_id={escape(meta.search_id)}</span>
109
+ <span class="chip">{escape(keys)}</span>
110
+ </div>
111
+ </header>
112
+ <main>
113
+ <section>
114
+ <h2>Overview</h2>
115
+ <div class="meta-grid">
116
+ {_meta_item("Generated", meta.generated_at)}
117
+ {_meta_item("PyLib version", meta.pylib_version)}
118
+ {_meta_item("Search duration", duration)}
119
+ {_meta_item("Rows processed", rows)}
120
+ {_meta_item("Samples", samples)}
121
+ </div>
122
+ </section>
123
+ {_summary_section(data.summary)}
124
+ {_quality_section(data.quality_by_sample)}
125
+ {_features_section(data.features)}
126
+ {_sources_section(data.sources)}
127
+ {_autofe_section(data.autofe)}
128
+ </main>
129
+ </body>
130
+ </html>
131
+ """
132
+
133
+
134
+ def _meta_item(label: str, value: str) -> str:
135
+ return (
136
+ f'<div class="meta-item"><div class="label">{escape(label)}</div>'
137
+ f'<div class="value">{escape(str(value))}</div></div>'
138
+ )
139
+
140
+
141
+ def _card(label: str, value) -> str:
142
+ return (
143
+ f'<div class="card"><div class="label">{escape(label)}</div>'
144
+ f'<div class="value">{escape(str(value))}</div></div>'
145
+ )
146
+
147
+
148
+ def _dash(value: Optional[object]) -> str:
149
+ if value is None or value == "":
150
+ return "—"
151
+ return str(value)
152
+
153
+
154
+ def _summary_section(summary: SearchResultsSummary) -> str:
155
+ if all(
156
+ value is None
157
+ for value in (
158
+ summary.relevant_features,
159
+ summary.model_features,
160
+ summary.data_sources,
161
+ summary.stable_features_share,
162
+ )
163
+ ):
164
+ return ""
165
+ stable = (
166
+ f"{summary.stable_features_share * 100:.0f}%"
167
+ if summary.stable_features_share is not None
168
+ else "—"
169
+ )
170
+ return f"""<section>
171
+ <h2>Summary</h2>
172
+ <div class="cards">
173
+ {_card("Features found", _dash(summary.relevant_features))}
174
+ {_card("Used in model", _dash(summary.model_features))}
175
+ {_card("Data sources", _dash(summary.data_sources))}
176
+ {_card("Stable features", stable)}
177
+ </div>
178
+ </section>"""
179
+
180
+
181
+ def _quality_section(samples: list[QualitySample]) -> str:
182
+ if not samples:
183
+ return '<section><h2>Key result</h2><p class="empty">Metrics were not calculated for this search.</p></section>'
184
+ metric = samples[0].metric or "score"
185
+ rows = []
186
+ for sample in samples:
187
+ rows.append(
188
+ "<tr>"
189
+ f"<td>{escape(sample.evaluation_scope)}</td>"
190
+ f"<td class='num'>{escape(_dash(sample.baseline))}</td>"
191
+ f"<td class='num'>{escape(_dash(sample.enriched))}</td>"
192
+ f"<td class='num uplift'>{escape(_dash(sample.uplift))}</td>"
193
+ f"<td class='num uplift'>{escape(_dash(sample.relative_uplift))}</td>"
194
+ "</tr>"
195
+ )
196
+ return f"""<section>
197
+ <h2>Key result · {escape(metric)}</h2>
198
+ <table>
199
+ <thead>
200
+ <tr>
201
+ <th>Scope</th>
202
+ <th class="num">Baseline</th><th class="num">Enriched</th>
203
+ <th class="num">Uplift, abs</th><th class="num">Uplift, %</th>
204
+ </tr>
205
+ </thead>
206
+ <tbody>{''.join(rows)}</tbody>
207
+ </table>
208
+ </section>"""
209
+
210
+
211
+ def _features_section(features: list[FeatureRow]) -> str:
212
+ if not features:
213
+ return ""
214
+ rows = []
215
+ for idx, feature in enumerate(features, start=1):
216
+ rows.append(
217
+ "<tr>"
218
+ f"<td class='num'>{idx}</td>"
219
+ f"<td>{escape(feature.name)}</td>"
220
+ f"<td>{escape(feature.provider)}</td>"
221
+ f"<td>{escape(feature.source)}</td>"
222
+ f"<td class='num'>{escape(_dash(feature.shap))}</td>"
223
+ f"<td class='num'>{escape(_dash(feature.psi))}</td>"
224
+ f"<td class='num'>{escape(_dash(feature.drift))}</td>"
225
+ f"<td class='num'>{escape(_dash(feature.coverage))}</td>"
226
+ "</tr>"
227
+ )
228
+ return f"""<section>
229
+ <h2>Search results · features</h2>
230
+ <table>
231
+ <thead>
232
+ <tr>
233
+ <th class="num">#</th><th>Feature</th><th>Provider</th><th>Source</th>
234
+ <th class="num">SHAP</th><th class="num">PSI</th>
235
+ <th class="num">Drift</th><th class="num">Coverage %</th>
236
+ </tr>
237
+ </thead>
238
+ <tbody>{''.join(rows)}</tbody>
239
+ </table>
240
+ </section>"""
241
+
242
+
243
+ def _sources_section(sources: list[SourceRow]) -> str:
244
+ if not sources:
245
+ return ""
246
+ rows = []
247
+ for source in sources:
248
+ rows.append(
249
+ "<tr>"
250
+ f"<td>{escape(source.provider)}</td>"
251
+ f"<td>{escape(source.source)}</td>"
252
+ f"<td class='num'>{escape(_dash(source.shap_sum))}</td>"
253
+ f"<td class='num'>{escape(_dash(source.feature_count))}</td>"
254
+ "</tr>"
255
+ )
256
+ return f"""<section>
257
+ <h2>Search results · sources</h2>
258
+ <table>
259
+ <thead>
260
+ <tr><th>Provider</th><th>Source</th><th class="num">SHAP sum</th><th class="num">Features</th></tr>
261
+ </thead>
262
+ <tbody>{''.join(rows)}</tbody>
263
+ </table>
264
+ </section>"""
265
+
266
+
267
+ def _autofe_section(autofe: list[dict[str, str]]) -> str:
268
+ if not autofe:
269
+ return ""
270
+ columns = list(autofe[0].keys())
271
+ head = "".join(f"<th>{escape(col)}</th>" for col in columns)
272
+ rows = []
273
+ for item in autofe:
274
+ rows.append("<tr>" + "".join(f"<td>{escape(item.get(col, ''))}</td>" for col in columns) + "</tr>")
275
+ return f"""<section>
276
+ <h2>AutoFE</h2>
277
+ <table>
278
+ <thead><tr>{head}</tr></thead>
279
+ <tbody>{''.join(rows)}</tbody>
280
+ </table>
281
+ </section>"""
@@ -327,6 +327,37 @@ def prepare_and_show_report(
327
327
  return show_button_download_pdf(report, display_id=display_id, display_handle=display_handle)
328
328
 
329
329
 
330
+ def show_button_open_report(
331
+ path: str, title="Open full report", display_id: Optional[str] = None, display_handle=None
332
+ ):
333
+ import webbrowser
334
+ from pathlib import Path
335
+
336
+ from IPython.display import HTML, display
337
+ from ipywidgets import Button, Layout
338
+
339
+ resolved = Path(path).resolve()
340
+ path_html = f'<div style="margin-top:6px;font-size:12px;color:#57534e">{resolved}</div>'
341
+ link_html = f"""<div>
342
+ <a href="{resolved.as_uri()}" target="_blank" rel="noopener noreferrer">
343
+ <button type="button">{title}</button>
344
+ </a>
345
+ {path_html}
346
+ </div>"""
347
+ if display_handle is not None:
348
+ display_handle.update(HTML(link_html))
349
+ return
350
+
351
+ button = Button(description=title, layout=Layout(width="auto"))
352
+
353
+ def on_click(b):
354
+ webbrowser.open(resolved.as_uri())
355
+
356
+ button.on_click(on_click)
357
+ display(button)
358
+ return display(HTML(path_html), display_id=display_id)
359
+
360
+
330
361
  def show_button_download_pdf(
331
362
  source: str, title="\U0001f4ca Download PDF report", display_id: Optional[str] = None, display_handle=None
332
363
  ):
@@ -1 +0,0 @@
1
- __version__ = "1.2.166.a4253.dev2"