upgini 1.2.166a4253.dev2__tar.gz → 1.2.167a4220.dev2__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.dev2}/PKG-INFO +1 -1
  2. upgini-1.2.167a4220.dev2/src/upgini/__about__.py +1 -0
  3. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev2}/src/upgini/features_enricher.py +213 -61
  4. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev2}/src/upgini/metrics.py +40 -6
  5. upgini-1.2.167a4220.dev2/src/upgini/report/__init__.py +5 -0
  6. upgini-1.2.167a4220.dev2/src/upgini/report/assemble.py +134 -0
  7. upgini-1.2.167a4220.dev2/src/upgini/report/data.py +87 -0
  8. upgini-1.2.167a4220.dev2/src/upgini/report/html.py +385 -0
  9. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev2}/src/upgini/resource_bundle/strings.properties +2 -0
  10. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev2}/src/upgini/utils/display_utils.py +52 -15
  11. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev2}/src/upgini/utils/track_info.py +4 -0
  12. upgini-1.2.166a4253.dev2/src/upgini/__about__.py +0 -1
  13. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev2}/.gitignore +0 -0
  14. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev2}/LICENSE +0 -0
  15. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev2}/README.md +0 -0
  16. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev2}/pyproject.toml +0 -0
  17. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev2}/src/upgini/__init__.py +0 -0
  18. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev2}/src/upgini/ads.py +0 -0
  19. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev2}/src/upgini/ads_management/__init__.py +0 -0
  20. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev2}/src/upgini/ads_management/ads_manager.py +0 -0
  21. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev2}/src/upgini/autofe/__init__.py +0 -0
  22. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev2}/src/upgini/autofe/all_operators.py +0 -0
  23. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev2}/src/upgini/autofe/binary.py +0 -0
  24. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev2}/src/upgini/autofe/date.py +0 -0
  25. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev2}/src/upgini/autofe/feature.py +0 -0
  26. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev2}/src/upgini/autofe/groupby.py +0 -0
  27. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev2}/src/upgini/autofe/operand.py +0 -0
  28. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev2}/src/upgini/autofe/operator.py +0 -0
  29. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev2}/src/upgini/autofe/timeseries/__init__.py +0 -0
  30. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev2}/src/upgini/autofe/timeseries/base.py +0 -0
  31. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev2}/src/upgini/autofe/timeseries/cross.py +0 -0
  32. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev2}/src/upgini/autofe/timeseries/delta.py +0 -0
  33. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev2}/src/upgini/autofe/timeseries/lag.py +0 -0
  34. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev2}/src/upgini/autofe/timeseries/numpy_kernels.py +0 -0
  35. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev2}/src/upgini/autofe/timeseries/roll.py +0 -0
  36. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev2}/src/upgini/autofe/timeseries/trend.py +0 -0
  37. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev2}/src/upgini/autofe/timeseries/volatility.py +0 -0
  38. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev2}/src/upgini/autofe/unary.py +0 -0
  39. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev2}/src/upgini/autofe/utils.py +0 -0
  40. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev2}/src/upgini/autofe/vector.py +0 -0
  41. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev2}/src/upgini/data_source/__init__.py +0 -0
  42. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev2}/src/upgini/data_source/data_source_publisher.py +0 -0
  43. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev2}/src/upgini/dataset.py +0 -0
  44. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev2}/src/upgini/errors.py +0 -0
  45. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev2}/src/upgini/http.py +0 -0
  46. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev2}/src/upgini/mdc/__init__.py +0 -0
  47. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev2}/src/upgini/mdc/context.py +0 -0
  48. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev2}/src/upgini/metadata.py +0 -0
  49. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev2}/src/upgini/normalizer/__init__.py +0 -0
  50. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev2}/src/upgini/normalizer/normalize_utils.py +0 -0
  51. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev2}/src/upgini/resource_bundle/__init__.py +0 -0
  52. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev2}/src/upgini/resource_bundle/exceptions.py +0 -0
  53. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev2}/src/upgini/resource_bundle/strings_widget.properties +0 -0
  54. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev2}/src/upgini/sampler/__init__.py +0 -0
  55. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev2}/src/upgini/sampler/base.py +0 -0
  56. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev2}/src/upgini/sampler/random_under_sampler.py +0 -0
  57. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev2}/src/upgini/sampler/utils.py +0 -0
  58. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev2}/src/upgini/search_task.py +0 -0
  59. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev2}/src/upgini/spinner.py +0 -0
  60. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev2}/src/upgini/utils/Roboto-Regular.ttf +0 -0
  61. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev2}/src/upgini/utils/__init__.py +0 -0
  62. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev2}/src/upgini/utils/base_search_key_detector.py +0 -0
  63. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev2}/src/upgini/utils/blocked_time_series.py +0 -0
  64. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev2}/src/upgini/utils/config.py +0 -0
  65. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev2}/src/upgini/utils/country_utils.py +0 -0
  66. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev2}/src/upgini/utils/cpu_utils.py +0 -0
  67. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev2}/src/upgini/utils/custom_loss_utils.py +0 -0
  68. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev2}/src/upgini/utils/cv_utils.py +0 -0
  69. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev2}/src/upgini/utils/datetime_utils.py +0 -0
  70. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev2}/src/upgini/utils/deduplicate_utils.py +0 -0
  71. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev2}/src/upgini/utils/email_utils.py +0 -0
  72. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev2}/src/upgini/utils/fallback_progress_bar.py +0 -0
  73. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev2}/src/upgini/utils/feature_info.py +0 -0
  74. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev2}/src/upgini/utils/features_validator.py +0 -0
  75. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev2}/src/upgini/utils/format.py +0 -0
  76. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev2}/src/upgini/utils/hash_utils.py +0 -0
  77. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev2}/src/upgini/utils/ip_utils.py +0 -0
  78. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev2}/src/upgini/utils/mstats.py +0 -0
  79. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev2}/src/upgini/utils/one_hot_encoder.py +0 -0
  80. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev2}/src/upgini/utils/phone_utils.py +0 -0
  81. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev2}/src/upgini/utils/postal_code_utils.py +0 -0
  82. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev2}/src/upgini/utils/progress_bar.py +0 -0
  83. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev2}/src/upgini/utils/psi.py +0 -0
  84. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev2}/src/upgini/utils/pyarrow_utils.py +0 -0
  85. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev2}/src/upgini/utils/sample_utils.py +0 -0
  86. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev2}/src/upgini/utils/search_key_derivations.py +0 -0
  87. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev2}/src/upgini/utils/sklearn_ext.py +0 -0
  88. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev2}/src/upgini/utils/sort.py +0 -0
  89. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev2}/src/upgini/utils/target_utils.py +0 -0
  90. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev2}/src/upgini/utils/ts_utils.py +0 -0
  91. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev2}/src/upgini/utils/warning_counter.py +0 -0
  92. {upgini-1.2.166a4253.dev2 → upgini-1.2.167a4220.dev2}/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.dev2
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.dev2"
@@ -68,7 +68,13 @@ from upgini.metadata import (
68
68
  RuntimeParameters,
69
69
  SearchKey,
70
70
  )
71
- from upgini.metrics import EstimatorWrapper, define_scorer, validate_scoring_argument
71
+ from upgini.metrics import (
72
+ EstimatorWrapper,
73
+ define_scorer,
74
+ format_metrics_for_display,
75
+ put_cv_metric,
76
+ validate_scoring_argument,
77
+ )
72
78
  from upgini.normalizer.normalize_utils import Normalizer
73
79
  from upgini.resource_bundle import ResourceBundle, bundle, get_custom_bundle
74
80
  from upgini.search_task import SearchTask
@@ -92,10 +98,14 @@ from upgini.utils.deduplicate_utils import (
92
98
  clean_full_duplicates,
93
99
  remove_fintech_duplicates,
94
100
  )
101
+ from upgini.report.assemble import assemble_report_data
102
+ from upgini.report.html import generate_html_report
95
103
  from upgini.utils.display_utils import (
96
104
  display_html_dataframe,
97
105
  do_without_pandas_limits,
106
+ ipython_available,
98
107
  prepare_and_show_report,
108
+ show_button_open_report,
99
109
  show_request_quote_button,
100
110
  )
101
111
  from upgini.utils.email_utils import (
@@ -175,6 +185,9 @@ class FeaturesEnricher(TransformerMixin):
175
185
 
176
186
  shared_datasets: list of str, optional (default=None)
177
187
  list of private shared dataset ids for custom search
188
+
189
+ reports_path: str, optional (default=None)
190
+ Directory for the HTML search report. Defaults to a `reports` folder in the current working directory.
178
191
  """
179
192
 
180
193
  TARGET_NAME = "target"
@@ -252,6 +265,7 @@ class FeaturesEnricher(TransformerMixin):
252
265
  sample_config: SampleConfig | None = None,
253
266
  print_trace_id: bool = False,
254
267
  print_loaded_report: bool = True,
268
+ reports_path: str | None = None,
255
269
  **kwargs,
256
270
  ):
257
271
  self.bundle = get_custom_bundle(custom_bundle_config)
@@ -310,6 +324,8 @@ class FeaturesEnricher(TransformerMixin):
310
324
  self.relevant_data_sources: pd.DataFrame = self.EMPTY_DATA_SOURCES
311
325
  self._relevant_data_sources_wo_links: pd.DataFrame = self.EMPTY_DATA_SOURCES
312
326
  self.metrics: pd.DataFrame | None = None
327
+ self.metrics_raw: pd.DataFrame | None = None
328
+ self.metrics_metric_name: str | None = None
313
329
  self.feature_names_ = []
314
330
  self.external_source_feature_names = []
315
331
  self.feature_importances_ = []
@@ -319,6 +335,9 @@ class FeaturesEnricher(TransformerMixin):
319
335
  self.disable_force_downsampling = disable_force_downsampling
320
336
  self.print_trace_id = print_trace_id
321
337
  self.add_info: AddInfo | None = None
338
+ self.reports_path = reports_path
339
+ self.search_duration_seconds: float | None = None
340
+ self.report_file_path: str | None = None
322
341
 
323
342
  if search_id:
324
343
  search_task = SearchTask(search_id, rest_client=self.rest_client, logger=self.logger)
@@ -1096,8 +1115,8 @@ class FeaturesEnricher(TransformerMixin):
1096
1115
 
1097
1116
  # 1 If client features are presented - fit and predict with KFold estimator
1098
1117
  # on etalon features and calculate baseline metric
1099
- baseline_metric = None
1100
1118
  baseline_estimator = None
1119
+ baseline_cv_result = None
1101
1120
  updating_shaps = None
1102
1121
  custom_loss_add_params = get_additional_params_custom_loss(
1103
1122
  self.loss, model_task_type, logger=self.logger
@@ -1121,35 +1140,27 @@ class FeaturesEnricher(TransformerMixin):
1121
1140
  baseline_cv_result = baseline_estimator.cross_val_predict(
1122
1141
  fitting_X, y_sorted, self.baseline_score_column
1123
1142
  )
1124
- baseline_metric = baseline_cv_result.get_display_metric()
1125
- if baseline_metric is None:
1143
+ if baseline_cv_result.metric is None:
1126
1144
  self.logger.info(
1127
1145
  f"Baseline {metric} on train client features is None (maybe all features was removed)"
1128
1146
  )
1129
1147
  baseline_estimator = None
1130
1148
  else:
1131
- self.logger.info(f"Baseline {metric} on train client features: {baseline_metric}")
1149
+ self.logger.info(
1150
+ f"Baseline {metric} on train client features: {baseline_cv_result.get_display_metric()}"
1151
+ )
1132
1152
  updating_shaps = baseline_cv_result.shap_values
1133
1153
 
1134
1154
  # 2 Fit and predict with KFold estimator on enriched tds
1135
1155
  # and calculate final metric (and uplift)
1136
- enriched_metric = None
1137
1156
  uplift = None
1138
1157
  uplift_perc = None
1139
1158
  enriched_estimator = None
1159
+ enriched_cv_result = None
1140
1160
  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(
1161
+
1162
+ def evaluate_enriched(score_column, description: str):
1163
+ wrapper = EstimatorWrapper.create(
1153
1164
  estimator,
1154
1165
  self.logger,
1155
1166
  model_task_type,
@@ -1161,22 +1172,47 @@ class FeaturesEnricher(TransformerMixin):
1161
1172
  text_features=text_features,
1162
1173
  has_time=has_time,
1163
1174
  )
1164
- enriched_cv_result = enriched_estimator.cross_val_predict(
1165
- fitting_enriched_X, enriched_y_sorted, ensemble_score_column
1175
+ cv_result = wrapper.cross_val_predict(
1176
+ fitting_enriched_X, enriched_y_sorted, score_column
1177
+ )
1178
+ if cv_result.metric is None:
1179
+ self.logger.warning(f"Enriched {metric} {description} is None")
1180
+ return None, cv_result, None, None
1181
+ self.logger.info(f"Enriched {metric} {description}: {cv_result.get_display_metric()}")
1182
+ sample_uplift = None
1183
+ sample_uplift_perc = None
1184
+ if baseline_cv_result is not None and baseline_cv_result.metric is not None:
1185
+ sample_uplift = (cv_result.metric - baseline_cv_result.metric) * multiplier
1186
+ sample_uplift_perc = sample_uplift / abs(baseline_cv_result.metric) * 100
1187
+ return wrapper, cv_result, sample_uplift, sample_uplift_perc
1188
+
1189
+ if ensemble_score_column is not None:
1190
+ self.logger.info(
1191
+ f"Only ensemble model returned; calculate enriched {metric} via roc_auc "
1192
+ f"on ensemble score column: {ensemble_score_column}"
1193
+ )
1194
+ (
1195
+ enriched_estimator,
1196
+ enriched_cv_result,
1197
+ uplift,
1198
+ uplift_perc,
1199
+ ) = evaluate_enriched(
1200
+ ensemble_score_column,
1201
+ f"on ensemble score column {ensemble_score_column}",
1166
1202
  )
1167
- enriched_metric = enriched_cv_result.get_display_metric()
1168
1203
  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
1204
+ elif set(fitting_X.columns) != set(fitting_enriched_X.columns):
1205
+ self.logger.info(
1206
+ f"Calculate enriched {metric} on train combined "
1207
+ f"features: {fitting_enriched_X.columns.to_list()}"
1208
+ )
1209
+ (
1210
+ enriched_estimator,
1211
+ enriched_cv_result,
1212
+ uplift,
1213
+ uplift_perc,
1214
+ ) = evaluate_enriched(None, "on train combined features")
1215
+ updating_shaps = enriched_cv_result.shap_values
1180
1216
 
1181
1217
  train_metrics = {
1182
1218
  self.bundle.get("quality_metrics_segment_header"): self.bundle.get(
@@ -1194,14 +1230,22 @@ class FeaturesEnricher(TransformerMixin):
1194
1230
  np.mean(y_sorted),
1195
1231
  4,
1196
1232
  )
1197
- if baseline_metric is not None:
1198
- train_metrics[self.bundle.get("quality_metrics_baseline_header").format(metric)] = (
1199
- baseline_metric
1200
- )
1201
- if enriched_metric is not None:
1202
- train_metrics[self.bundle.get("quality_metrics_enriched_header").format(metric)] = (
1203
- enriched_metric
1204
- )
1233
+ put_cv_metric(
1234
+ train_metrics,
1235
+ "quality_metrics_baseline_header",
1236
+ "quality_metrics_baseline_std_header",
1237
+ baseline_cv_result,
1238
+ metric,
1239
+ self.bundle,
1240
+ )
1241
+ put_cv_metric(
1242
+ train_metrics,
1243
+ "quality_metrics_enriched_header",
1244
+ "quality_metrics_enriched_std_header",
1245
+ enriched_cv_result,
1246
+ metric,
1247
+ self.bundle,
1248
+ )
1205
1249
  if uplift is not None:
1206
1250
  train_metrics[self.bundle.get("quality_metrics_uplift_header")] = round(uplift, 3)
1207
1251
  train_metrics[self.bundle.get("quality_metrics_uplift_perc_header")] = (
@@ -1233,12 +1277,13 @@ class FeaturesEnricher(TransformerMixin):
1233
1277
  etalon_eval_results = baseline_estimator.calculate_metric(
1234
1278
  eval_X_sorted, eval_y_sorted, self.baseline_score_column
1235
1279
  )
1236
- etalon_eval_metric = etalon_eval_results.get_display_metric()
1237
1280
  self.logger.info(
1238
- f"Baseline {metric} on eval set {idx + 1} client features: {etalon_eval_metric}"
1281
+ "Baseline {} on eval set {} client features: {}".format(
1282
+ metric, idx + 1, etalon_eval_results.get_display_metric()
1283
+ )
1239
1284
  )
1240
1285
  else:
1241
- etalon_eval_metric = None
1286
+ etalon_eval_results = None
1242
1287
 
1243
1288
  if enriched_estimator is not None:
1244
1289
  self.logger.info(
@@ -1248,14 +1293,20 @@ class FeaturesEnricher(TransformerMixin):
1248
1293
  enriched_eval_results = enriched_estimator.calculate_metric(
1249
1294
  enriched_eval_X_sorted, enriched_eval_y_sorted, ensemble_score_column
1250
1295
  )
1251
- enriched_eval_metric = enriched_eval_results.get_display_metric()
1252
1296
  self.logger.info(
1253
- f"Enriched {metric} on eval set {idx + 1} combined features: {enriched_eval_metric}"
1297
+ "Enriched {} on eval set {} combined features: {}".format(
1298
+ metric, idx + 1, enriched_eval_results.get_display_metric()
1299
+ )
1254
1300
  )
1255
1301
  else:
1256
- enriched_eval_metric = None
1302
+ enriched_eval_results = None
1257
1303
 
1258
- if etalon_eval_metric is not None and enriched_eval_metric is not None:
1304
+ if (
1305
+ etalon_eval_results is not None
1306
+ and etalon_eval_results.metric is not None
1307
+ and enriched_eval_results is not None
1308
+ and enriched_eval_results.metric is not None
1309
+ ):
1259
1310
  eval_uplift = (enriched_eval_results.metric - etalon_eval_results.metric) * multiplier
1260
1311
  eval_uplift_perc = eval_uplift / abs(etalon_eval_results.metric) * 100
1261
1312
  else:
@@ -1279,14 +1330,22 @@ class FeaturesEnricher(TransformerMixin):
1279
1330
  np.mean(eval_y_sorted),
1280
1331
  4,
1281
1332
  )
1282
- if etalon_eval_metric is not None:
1283
- eval_metrics[self.bundle.get("quality_metrics_baseline_header").format(metric)] = (
1284
- etalon_eval_metric
1285
- )
1286
- if enriched_eval_metric is not None:
1287
- eval_metrics[self.bundle.get("quality_metrics_enriched_header").format(metric)] = (
1288
- enriched_eval_metric
1289
- )
1333
+ put_cv_metric(
1334
+ eval_metrics,
1335
+ "quality_metrics_baseline_header",
1336
+ "quality_metrics_baseline_std_header",
1337
+ etalon_eval_results,
1338
+ metric,
1339
+ self.bundle,
1340
+ )
1341
+ put_cv_metric(
1342
+ eval_metrics,
1343
+ "quality_metrics_enriched_header",
1344
+ "quality_metrics_enriched_std_header",
1345
+ enriched_eval_results,
1346
+ metric,
1347
+ self.bundle,
1348
+ )
1290
1349
  if eval_uplift is not None:
1291
1350
  eval_metrics[self.bundle.get("quality_metrics_uplift_header")] = round(eval_uplift, 3)
1292
1351
  eval_metrics[self.bundle.get("quality_metrics_uplift_perc_header")] = (
@@ -1323,7 +1382,9 @@ class FeaturesEnricher(TransformerMixin):
1323
1382
  elif uplift_col in metrics_df.columns and (metrics_df[uplift_col] < 0).any():
1324
1383
  self.logger.warning("Uplift is negative")
1325
1384
 
1326
- return metrics_df
1385
+ self.metrics_raw = metrics_df
1386
+ self.metrics_metric_name = metric
1387
+ return format_metrics_for_display(metrics_df, metric, self.bundle)
1327
1388
  except Exception as e:
1328
1389
  error_message = "Failed to calculate metrics" + (
1329
1390
  " with validation error" if isinstance(e, ValidationError) else ""
@@ -2224,6 +2285,71 @@ class FeaturesEnricher(TransformerMixin):
2224
2285
  column = new_external[0]
2225
2286
  return column if self._is_ensemble_feature(column) else None
2226
2287
 
2288
+ def _has_single_ensemble_score(self) -> bool:
2289
+ return self._get_single_ensemble_score_name() is not None
2290
+
2291
+ def _get_single_ensemble_score_name(self) -> str | None:
2292
+ selected = list(self.feature_names_ or [])
2293
+ ensemble = [name for name in selected if self._is_ensemble_feature(name)]
2294
+ if len(ensemble) != 1:
2295
+ return None
2296
+ etalon_columns = set()
2297
+ if self._search_task is not None:
2298
+ etalon_columns = {
2299
+ meta.name
2300
+ for meta in (self._search_task.get_all_features_metadata_v2() or [])
2301
+ if meta.source == "etalon"
2302
+ }
2303
+ extras = [name for name in selected if name != ensemble[0] and name not in etalon_columns]
2304
+ if extras:
2305
+ return None
2306
+ return ensemble[0]
2307
+
2308
+ def _reports_dir(self) -> Path:
2309
+ return Path(self.reports_path) if self.reports_path else Path.cwd() / "reports"
2310
+
2311
+ def _reference_rows(self) -> int | None:
2312
+ if self.X is None:
2313
+ return None
2314
+ rows = _num_samples(self.X)
2315
+ for eval_pair in self.eval_set or []:
2316
+ rows += _num_samples(eval_pair[0])
2317
+ return rows
2318
+
2319
+ def _report_sample_names(self) -> list[str]:
2320
+ samples = [self.bundle.get("quality_metrics_train_segment")]
2321
+ for idx, _ in enumerate(self.eval_set or []):
2322
+ samples.append(self.bundle.get("quality_metrics_eval_segment").format(idx + 1))
2323
+ return samples
2324
+
2325
+ def _assemble_report_data(self):
2326
+ search_id = self._search_task.search_task_id if self._search_task is not None else (self.search_id or "")
2327
+ return assemble_report_data(
2328
+ search_id=search_id,
2329
+ search_keys=[str(sk) for sk in self.search_keys.values()],
2330
+ search_duration_seconds=self.search_duration_seconds,
2331
+ reference_rows=self._reference_rows(),
2332
+ samples=self._report_sample_names(),
2333
+ metrics_df=self.metrics_raw if self.metrics_raw is not None else self.metrics,
2334
+ metric_name=self.metrics_metric_name,
2335
+ model_features=self._ensemble_model_feature_count(),
2336
+ is_binary=self.model_task_type == ModelTaskType.BINARY,
2337
+ bundle=self.bundle,
2338
+ )
2339
+
2340
+ def _write_score_report(self) -> str | None:
2341
+ if self._search_task is None or not self._has_single_ensemble_score():
2342
+ return None
2343
+ report_data = self._assemble_report_data()
2344
+ html = generate_html_report(report_data)
2345
+ reports_dir = self._reports_dir()
2346
+ reports_dir.mkdir(parents=True, exist_ok=True)
2347
+ path = reports_dir / f"upgini-report-{report_data.metadata.search_id}.html"
2348
+ path.write_text(html, encoding="utf-8")
2349
+ self.report_file_path = str(path)
2350
+ self.logger.info(f"Score report saved to {path}")
2351
+ return self.report_file_path
2352
+
2227
2353
  def _is_ensemble_feature(self, column_name: str) -> bool:
2228
2354
  renaming = self.fit_columns_renaming or {}
2229
2355
  aliases = self._column_name_aliases([column_name], renaming)
@@ -2232,15 +2358,20 @@ class FeaturesEnricher(TransformerMixin):
2232
2358
  return False
2233
2359
  return bool(aliases & self._column_name_aliases(list(ensemble_names), renaming))
2234
2360
 
2235
- def _ensemble_feature_names_from_metadata(self) -> set[str]:
2361
+ def _ensemble_generated_metadata(self):
2236
2362
  autofe_meta = self._search_task.get_autofe_metadata() if self._search_task else None
2237
2363
  if not autofe_meta:
2238
- return set()
2239
- names: set[str] = set()
2364
+ return []
2365
+ matched = []
2240
2366
  for meta in autofe_meta:
2241
2367
  op = EnsembleModel.from_formula(meta.formula.split("(")[0])
2242
- if op is None:
2243
- continue
2368
+ if op is not None:
2369
+ matched.append((meta, op))
2370
+ return matched
2371
+
2372
+ def _ensemble_feature_names_from_metadata(self) -> set[str]:
2373
+ names: set[str] = set()
2374
+ for meta, op in self._ensemble_generated_metadata():
2244
2375
  feature = (
2245
2376
  Feature(op, [])
2246
2377
  .set_display_index(meta.display_index)
@@ -2252,6 +2383,14 @@ class FeaturesEnricher(TransformerMixin):
2252
2383
  names.add(meta.alias)
2253
2384
  return names
2254
2385
 
2386
+ def _ensemble_model_feature_count(self) -> int | None:
2387
+ names = {
2388
+ column.original_name
2389
+ for meta, _ in self._ensemble_generated_metadata()
2390
+ for column in meta.base_columns
2391
+ }
2392
+ return len(names) or None
2393
+
2255
2394
  def _psi_exceeds_threshold(self, psi: float | None, threshold: float) -> bool:
2256
2395
  return psi is not None and not pd.isna(psi) and psi > threshold
2257
2396
 
@@ -4126,6 +4265,9 @@ if response.status_code == 200:
4126
4265
  self._fit_match_search_keys = None
4127
4266
  self.__cached_sampled_datasets = dict()
4128
4267
  self.metrics = None
4268
+ self.metrics_raw = None
4269
+ self.metrics_metric_name = None
4270
+ self.report_file_path = None
4129
4271
  self.fit_columns_renaming = None
4130
4272
  self.fit_dropped_features = set()
4131
4273
  self.fit_generated_features = []
@@ -4536,6 +4678,7 @@ if response.status_code == 200:
4536
4678
 
4537
4679
  self._search_task.poll_result(self._get_trace_id(), quiet=True)
4538
4680
  self.__persist_transform_match_hashes()
4681
+ self.search_duration_seconds = time.time() - start_time
4539
4682
 
4540
4683
  seconds_left = time.time() - start_time
4541
4684
  progress = SearchProgress(97.0, ProgressStage.GENERATING_REPORT, seconds_left)
@@ -5942,6 +6085,15 @@ if response.status_code == 200:
5942
6085
 
5943
6086
  def __show_report_button(self, display_id: str | None = None, display_handle=None):
5944
6087
  try:
6088
+ report_path = self._write_score_report() or self.report_file_path
6089
+ if report_path:
6090
+ if not ipython_available():
6091
+ print(f"Full report saved to {report_path}")
6092
+ return
6093
+ return show_button_open_report(
6094
+ report_path, display_id=display_id, display_handle=display_handle
6095
+ )
6096
+
5945
6097
  eval_sets_drift_df = self._get_eval_sets_drift_summary()
5946
6098
 
5947
6099
  return prepare_and_show_report(
@@ -253,12 +253,46 @@ class _CrossValResults:
253
253
  shap_values: Optional[Dict[str, float]]
254
254
 
255
255
  def get_display_metric(self) -> Optional[str]:
256
- if self.metric is None:
257
- return None
258
- elif self.metric_std is None:
259
- return f"{self.metric:.3f}"
260
- else:
261
- return f"{self.metric:.3f} ± {self.metric_std:.3f}"
256
+ return format_display_metric(self.metric, self.metric_std)
257
+
258
+
259
+ def format_display_metric(metric: Optional[float], metric_std: Optional[float] = None) -> Optional[str]:
260
+ if metric is None or pd.isna(metric):
261
+ return None
262
+ if metric_std is None or pd.isna(metric_std):
263
+ return f"{float(metric):.3f}"
264
+ return f"{float(metric):.3f} ± {float(metric_std):.3f}"
265
+
266
+
267
+ def format_metrics_for_display(metrics_df: pd.DataFrame, metric_name: str, bundle) -> pd.DataFrame:
268
+ display = metrics_df.copy()
269
+ drop: list[str] = []
270
+ for value_key, std_key in (
271
+ ("quality_metrics_baseline_header", "quality_metrics_baseline_std_header"),
272
+ ("quality_metrics_enriched_header", "quality_metrics_enriched_std_header"),
273
+ ):
274
+ col = bundle.get(value_key).format(metric_name)
275
+ std_col = bundle.get(std_key).format(metric_name)
276
+ if col in display.columns:
277
+ stds = display[std_col] if std_col in display.columns else [None] * len(display)
278
+ display[col] = [format_display_metric(value, std) for value, std in zip(display[col], stds)]
279
+ if std_col in display.columns:
280
+ drop.append(std_col)
281
+ return display.drop(columns=drop, errors="ignore")
282
+
283
+
284
+ def put_cv_metric(
285
+ row: dict,
286
+ value_key: str,
287
+ std_key: str,
288
+ result: Optional[_CrossValResults],
289
+ metric_name: str,
290
+ bundle,
291
+ ) -> None:
292
+ if result is None or result.metric is None:
293
+ return
294
+ row[bundle.get(value_key).format(metric_name)] = result.metric
295
+ row[bundle.get(std_key).format(metric_name)] = result.metric_std
262
296
 
263
297
 
264
298
  def is_numeric_object(x: pd.Series) -> bool:
@@ -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,134 @@
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, SampleStats, SearchResultsSummary
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
+ model_features: Optional[int] = None,
38
+ is_binary: bool = False,
39
+ logo_url: Optional[str] = None,
40
+ bundle: ResourceBundle,
41
+ ) -> ReportData:
42
+ generated_at = generated_at or datetime.now(timezone.utc)
43
+ sample_names = list(samples or [])
44
+ return ReportData(
45
+ metadata=ReportMetadata(
46
+ search_id=search_id,
47
+ generated_at=generated_at.strftime("%Y-%m-%d %H:%M UTC"),
48
+ pylib_version=pylib_version or __version__,
49
+ search_keys=list(search_keys or []),
50
+ search_duration=format_search_duration(search_duration_seconds),
51
+ reference_rows=reference_rows,
52
+ samples=sample_names,
53
+ logo_url=logo_url or None,
54
+ ),
55
+ quality_by_sample=_quality_samples(metrics_df, metric_name, bundle),
56
+ summary=SearchResultsSummary(model_features=model_features),
57
+ sample_stats=_sample_stats(sample_names, metrics_df, bundle),
58
+ is_binary=is_binary,
59
+ )
60
+
61
+
62
+ def _quality_samples(
63
+ metrics_df: Optional[pd.DataFrame], metric_name: Optional[str], bundle: ResourceBundle
64
+ ) -> list[QualitySample]:
65
+ if metrics_df is None or metrics_df.empty:
66
+ return []
67
+ segment_col = bundle.get("quality_metrics_segment_header")
68
+ uplift_col = bundle.get("quality_metrics_uplift_header")
69
+ uplift_pct_col = bundle.get("quality_metrics_uplift_perc_header")
70
+ baseline_col = bundle.get("quality_metrics_baseline_header").format(metric_name) if metric_name else None
71
+ enriched_col = bundle.get("quality_metrics_enriched_header").format(metric_name) if metric_name else None
72
+ baseline_std_col = bundle.get("quality_metrics_baseline_std_header").format(metric_name) if metric_name else None
73
+ enriched_std_col = bundle.get("quality_metrics_enriched_std_header").format(metric_name) if metric_name else None
74
+
75
+ samples: list[QualitySample] = []
76
+ for _, row in metrics_df.iterrows():
77
+ samples.append(
78
+ QualitySample(
79
+ evaluation_scope=(_as_str(row[segment_col]) or "") if segment_col in metrics_df.columns else "",
80
+ metric=metric_name or "",
81
+ baseline=_as_float(row[baseline_col]) if baseline_col in metrics_df.columns else None,
82
+ enriched=_as_float(row[enriched_col]) if enriched_col in metrics_df.columns else None,
83
+ std=_metric_std(row, metrics_df.columns, enriched_std_col, baseline_std_col),
84
+ uplift=_as_float(row[uplift_col]) if uplift_col in metrics_df.columns else None,
85
+ relative_uplift=_as_str(row[uplift_pct_col]) if uplift_pct_col in metrics_df.columns else None,
86
+ )
87
+ )
88
+ return samples
89
+
90
+
91
+ def _sample_stats(
92
+ sample_names: list[str], metrics_df: Optional[pd.DataFrame], bundle: ResourceBundle
93
+ ) -> list[SampleStats]:
94
+ segment_col = bundle.get("quality_metrics_segment_header")
95
+ rows_col = bundle.get("quality_metrics_rows_header")
96
+ mean_col = bundle.get("quality_metrics_mean_target_header")
97
+ by_name: dict[str, SampleStats] = {}
98
+ if metrics_df is not None and not metrics_df.empty and segment_col in metrics_df.columns:
99
+ for _, row in metrics_df.iterrows():
100
+ name = _as_str(row[segment_col])
101
+ if not name:
102
+ continue
103
+ by_name[name] = SampleStats(
104
+ sample=name,
105
+ rows=_as_int(row[rows_col]) if rows_col in metrics_df.columns else None,
106
+ mean_target=_as_float(row[mean_col]) if mean_col in metrics_df.columns else None,
107
+ )
108
+ names = sample_names or list(by_name)
109
+ stats = [by_name.get(name) or SampleStats(sample=name) for name in names]
110
+ seen = set(names)
111
+ stats.extend(sample for name, sample in by_name.items() if name not in seen)
112
+ return stats
113
+
114
+
115
+ def _metric_std(row, columns, enriched_std_col: Optional[str], baseline_std_col: Optional[str]) -> Optional[float]:
116
+ if enriched_std_col in columns:
117
+ std = _as_float(row[enriched_std_col])
118
+ if std is not None:
119
+ return std
120
+ if baseline_std_col in columns:
121
+ return _as_float(row[baseline_std_col])
122
+ return None
123
+
124
+
125
+ def _as_str(value) -> Optional[str]:
126
+ return None if pd.isna(value) else str(value)
127
+
128
+
129
+ def _as_float(value) -> Optional[float]:
130
+ return None if pd.isna(value) else float(value)
131
+
132
+
133
+ def _as_int(value) -> Optional[int]:
134
+ return None if pd.isna(value) else int(value)