upgini 1.2.166a4253.dev1__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.dev1 → 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.dev1 → upgini-1.2.167a4220.dev1}/src/upgini/features_enricher.py +247 -50
  4. {upgini-1.2.166a4253.dev1 → upgini-1.2.167a4220.dev1}/src/upgini/metadata.py +4 -0
  5. upgini-1.2.167a4220.dev1/src/upgini/report/__init__.py +5 -0
  6. upgini-1.2.167a4220.dev1/src/upgini/report/assemble.py +85 -0
  7. upgini-1.2.167a4220.dev1/src/upgini/report/data.py +72 -0
  8. upgini-1.2.167a4220.dev1/src/upgini/report/html.py +281 -0
  9. {upgini-1.2.166a4253.dev1 → upgini-1.2.167a4220.dev1}/src/upgini/utils/display_utils.py +31 -0
  10. upgini-1.2.166a4253.dev1/src/upgini/__about__.py +0 -1
  11. {upgini-1.2.166a4253.dev1 → upgini-1.2.167a4220.dev1}/.gitignore +0 -0
  12. {upgini-1.2.166a4253.dev1 → upgini-1.2.167a4220.dev1}/LICENSE +0 -0
  13. {upgini-1.2.166a4253.dev1 → upgini-1.2.167a4220.dev1}/README.md +0 -0
  14. {upgini-1.2.166a4253.dev1 → upgini-1.2.167a4220.dev1}/pyproject.toml +0 -0
  15. {upgini-1.2.166a4253.dev1 → upgini-1.2.167a4220.dev1}/src/upgini/__init__.py +0 -0
  16. {upgini-1.2.166a4253.dev1 → upgini-1.2.167a4220.dev1}/src/upgini/ads.py +0 -0
  17. {upgini-1.2.166a4253.dev1 → upgini-1.2.167a4220.dev1}/src/upgini/ads_management/__init__.py +0 -0
  18. {upgini-1.2.166a4253.dev1 → upgini-1.2.167a4220.dev1}/src/upgini/ads_management/ads_manager.py +0 -0
  19. {upgini-1.2.166a4253.dev1 → upgini-1.2.167a4220.dev1}/src/upgini/autofe/__init__.py +0 -0
  20. {upgini-1.2.166a4253.dev1 → upgini-1.2.167a4220.dev1}/src/upgini/autofe/all_operators.py +0 -0
  21. {upgini-1.2.166a4253.dev1 → upgini-1.2.167a4220.dev1}/src/upgini/autofe/binary.py +0 -0
  22. {upgini-1.2.166a4253.dev1 → upgini-1.2.167a4220.dev1}/src/upgini/autofe/date.py +0 -0
  23. {upgini-1.2.166a4253.dev1 → upgini-1.2.167a4220.dev1}/src/upgini/autofe/feature.py +0 -0
  24. {upgini-1.2.166a4253.dev1 → upgini-1.2.167a4220.dev1}/src/upgini/autofe/groupby.py +0 -0
  25. {upgini-1.2.166a4253.dev1 → upgini-1.2.167a4220.dev1}/src/upgini/autofe/operand.py +0 -0
  26. {upgini-1.2.166a4253.dev1 → upgini-1.2.167a4220.dev1}/src/upgini/autofe/operator.py +0 -0
  27. {upgini-1.2.166a4253.dev1 → upgini-1.2.167a4220.dev1}/src/upgini/autofe/timeseries/__init__.py +0 -0
  28. {upgini-1.2.166a4253.dev1 → upgini-1.2.167a4220.dev1}/src/upgini/autofe/timeseries/base.py +0 -0
  29. {upgini-1.2.166a4253.dev1 → upgini-1.2.167a4220.dev1}/src/upgini/autofe/timeseries/cross.py +0 -0
  30. {upgini-1.2.166a4253.dev1 → upgini-1.2.167a4220.dev1}/src/upgini/autofe/timeseries/delta.py +0 -0
  31. {upgini-1.2.166a4253.dev1 → upgini-1.2.167a4220.dev1}/src/upgini/autofe/timeseries/lag.py +0 -0
  32. {upgini-1.2.166a4253.dev1 → upgini-1.2.167a4220.dev1}/src/upgini/autofe/timeseries/numpy_kernels.py +0 -0
  33. {upgini-1.2.166a4253.dev1 → upgini-1.2.167a4220.dev1}/src/upgini/autofe/timeseries/roll.py +0 -0
  34. {upgini-1.2.166a4253.dev1 → upgini-1.2.167a4220.dev1}/src/upgini/autofe/timeseries/trend.py +0 -0
  35. {upgini-1.2.166a4253.dev1 → upgini-1.2.167a4220.dev1}/src/upgini/autofe/timeseries/volatility.py +0 -0
  36. {upgini-1.2.166a4253.dev1 → upgini-1.2.167a4220.dev1}/src/upgini/autofe/unary.py +0 -0
  37. {upgini-1.2.166a4253.dev1 → upgini-1.2.167a4220.dev1}/src/upgini/autofe/utils.py +0 -0
  38. {upgini-1.2.166a4253.dev1 → upgini-1.2.167a4220.dev1}/src/upgini/autofe/vector.py +0 -0
  39. {upgini-1.2.166a4253.dev1 → upgini-1.2.167a4220.dev1}/src/upgini/data_source/__init__.py +0 -0
  40. {upgini-1.2.166a4253.dev1 → upgini-1.2.167a4220.dev1}/src/upgini/data_source/data_source_publisher.py +0 -0
  41. {upgini-1.2.166a4253.dev1 → upgini-1.2.167a4220.dev1}/src/upgini/dataset.py +0 -0
  42. {upgini-1.2.166a4253.dev1 → upgini-1.2.167a4220.dev1}/src/upgini/errors.py +0 -0
  43. {upgini-1.2.166a4253.dev1 → upgini-1.2.167a4220.dev1}/src/upgini/http.py +0 -0
  44. {upgini-1.2.166a4253.dev1 → upgini-1.2.167a4220.dev1}/src/upgini/mdc/__init__.py +0 -0
  45. {upgini-1.2.166a4253.dev1 → upgini-1.2.167a4220.dev1}/src/upgini/mdc/context.py +0 -0
  46. {upgini-1.2.166a4253.dev1 → upgini-1.2.167a4220.dev1}/src/upgini/metrics.py +0 -0
  47. {upgini-1.2.166a4253.dev1 → upgini-1.2.167a4220.dev1}/src/upgini/normalizer/__init__.py +0 -0
  48. {upgini-1.2.166a4253.dev1 → upgini-1.2.167a4220.dev1}/src/upgini/normalizer/normalize_utils.py +0 -0
  49. {upgini-1.2.166a4253.dev1 → upgini-1.2.167a4220.dev1}/src/upgini/resource_bundle/__init__.py +0 -0
  50. {upgini-1.2.166a4253.dev1 → upgini-1.2.167a4220.dev1}/src/upgini/resource_bundle/exceptions.py +0 -0
  51. {upgini-1.2.166a4253.dev1 → upgini-1.2.167a4220.dev1}/src/upgini/resource_bundle/strings.properties +0 -0
  52. {upgini-1.2.166a4253.dev1 → upgini-1.2.167a4220.dev1}/src/upgini/resource_bundle/strings_widget.properties +0 -0
  53. {upgini-1.2.166a4253.dev1 → upgini-1.2.167a4220.dev1}/src/upgini/sampler/__init__.py +0 -0
  54. {upgini-1.2.166a4253.dev1 → upgini-1.2.167a4220.dev1}/src/upgini/sampler/base.py +0 -0
  55. {upgini-1.2.166a4253.dev1 → upgini-1.2.167a4220.dev1}/src/upgini/sampler/random_under_sampler.py +0 -0
  56. {upgini-1.2.166a4253.dev1 → upgini-1.2.167a4220.dev1}/src/upgini/sampler/utils.py +0 -0
  57. {upgini-1.2.166a4253.dev1 → upgini-1.2.167a4220.dev1}/src/upgini/search_task.py +0 -0
  58. {upgini-1.2.166a4253.dev1 → upgini-1.2.167a4220.dev1}/src/upgini/spinner.py +0 -0
  59. {upgini-1.2.166a4253.dev1 → upgini-1.2.167a4220.dev1}/src/upgini/utils/Roboto-Regular.ttf +0 -0
  60. {upgini-1.2.166a4253.dev1 → upgini-1.2.167a4220.dev1}/src/upgini/utils/__init__.py +0 -0
  61. {upgini-1.2.166a4253.dev1 → upgini-1.2.167a4220.dev1}/src/upgini/utils/base_search_key_detector.py +0 -0
  62. {upgini-1.2.166a4253.dev1 → upgini-1.2.167a4220.dev1}/src/upgini/utils/blocked_time_series.py +0 -0
  63. {upgini-1.2.166a4253.dev1 → upgini-1.2.167a4220.dev1}/src/upgini/utils/config.py +0 -0
  64. {upgini-1.2.166a4253.dev1 → upgini-1.2.167a4220.dev1}/src/upgini/utils/country_utils.py +0 -0
  65. {upgini-1.2.166a4253.dev1 → upgini-1.2.167a4220.dev1}/src/upgini/utils/cpu_utils.py +0 -0
  66. {upgini-1.2.166a4253.dev1 → upgini-1.2.167a4220.dev1}/src/upgini/utils/custom_loss_utils.py +0 -0
  67. {upgini-1.2.166a4253.dev1 → upgini-1.2.167a4220.dev1}/src/upgini/utils/cv_utils.py +0 -0
  68. {upgini-1.2.166a4253.dev1 → upgini-1.2.167a4220.dev1}/src/upgini/utils/datetime_utils.py +0 -0
  69. {upgini-1.2.166a4253.dev1 → upgini-1.2.167a4220.dev1}/src/upgini/utils/deduplicate_utils.py +0 -0
  70. {upgini-1.2.166a4253.dev1 → upgini-1.2.167a4220.dev1}/src/upgini/utils/email_utils.py +0 -0
  71. {upgini-1.2.166a4253.dev1 → upgini-1.2.167a4220.dev1}/src/upgini/utils/fallback_progress_bar.py +0 -0
  72. {upgini-1.2.166a4253.dev1 → upgini-1.2.167a4220.dev1}/src/upgini/utils/feature_info.py +0 -0
  73. {upgini-1.2.166a4253.dev1 → upgini-1.2.167a4220.dev1}/src/upgini/utils/features_validator.py +0 -0
  74. {upgini-1.2.166a4253.dev1 → upgini-1.2.167a4220.dev1}/src/upgini/utils/format.py +0 -0
  75. {upgini-1.2.166a4253.dev1 → upgini-1.2.167a4220.dev1}/src/upgini/utils/hash_utils.py +0 -0
  76. {upgini-1.2.166a4253.dev1 → upgini-1.2.167a4220.dev1}/src/upgini/utils/ip_utils.py +0 -0
  77. {upgini-1.2.166a4253.dev1 → upgini-1.2.167a4220.dev1}/src/upgini/utils/mstats.py +0 -0
  78. {upgini-1.2.166a4253.dev1 → upgini-1.2.167a4220.dev1}/src/upgini/utils/one_hot_encoder.py +0 -0
  79. {upgini-1.2.166a4253.dev1 → upgini-1.2.167a4220.dev1}/src/upgini/utils/phone_utils.py +0 -0
  80. {upgini-1.2.166a4253.dev1 → upgini-1.2.167a4220.dev1}/src/upgini/utils/postal_code_utils.py +0 -0
  81. {upgini-1.2.166a4253.dev1 → upgini-1.2.167a4220.dev1}/src/upgini/utils/progress_bar.py +0 -0
  82. {upgini-1.2.166a4253.dev1 → upgini-1.2.167a4220.dev1}/src/upgini/utils/psi.py +0 -0
  83. {upgini-1.2.166a4253.dev1 → upgini-1.2.167a4220.dev1}/src/upgini/utils/pyarrow_utils.py +0 -0
  84. {upgini-1.2.166a4253.dev1 → upgini-1.2.167a4220.dev1}/src/upgini/utils/sample_utils.py +0 -0
  85. {upgini-1.2.166a4253.dev1 → upgini-1.2.167a4220.dev1}/src/upgini/utils/search_key_derivations.py +0 -0
  86. {upgini-1.2.166a4253.dev1 → upgini-1.2.167a4220.dev1}/src/upgini/utils/sklearn_ext.py +0 -0
  87. {upgini-1.2.166a4253.dev1 → upgini-1.2.167a4220.dev1}/src/upgini/utils/sort.py +0 -0
  88. {upgini-1.2.166a4253.dev1 → upgini-1.2.167a4220.dev1}/src/upgini/utils/target_utils.py +0 -0
  89. {upgini-1.2.166a4253.dev1 → upgini-1.2.167a4220.dev1}/src/upgini/utils/track_info.py +0 -0
  90. {upgini-1.2.166a4253.dev1 → upgini-1.2.167a4220.dev1}/src/upgini/utils/ts_utils.py +0 -0
  91. {upgini-1.2.166a4253.dev1 → upgini-1.2.167a4220.dev1}/src/upgini/utils/warning_counter.py +0 -0
  92. {upgini-1.2.166a4253.dev1 → 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.dev1
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"
@@ -32,6 +32,7 @@ from sklearn.preprocessing import OrdinalEncoder
32
32
 
33
33
  from upgini.autofe.feature import Feature
34
34
  from upgini.autofe.timeseries import TimeSeriesBase
35
+ from upgini.autofe.utils import pydantic_dump_method
35
36
  from upgini.autofe.vector import EnsembleModel
36
37
  from upgini.data_source.data_source_publisher import CommercialSchema
37
38
  from upgini.dataset import Dataset
@@ -91,10 +92,14 @@ from upgini.utils.deduplicate_utils import (
91
92
  clean_full_duplicates,
92
93
  remove_fintech_duplicates,
93
94
  )
95
+ from upgini.report.assemble import assemble_report_data
96
+ from upgini.report.html import generate_html_report
94
97
  from upgini.utils.display_utils import (
95
98
  display_html_dataframe,
96
99
  do_without_pandas_limits,
100
+ ipython_available,
97
101
  prepare_and_show_report,
102
+ show_button_open_report,
98
103
  show_request_quote_button,
99
104
  )
100
105
  from upgini.utils.email_utils import (
@@ -174,6 +179,9 @@ class FeaturesEnricher(TransformerMixin):
174
179
 
175
180
  shared_datasets: list of str, optional (default=None)
176
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.
177
185
  """
178
186
 
179
187
  TARGET_NAME = "target"
@@ -251,6 +259,7 @@ class FeaturesEnricher(TransformerMixin):
251
259
  sample_config: SampleConfig | None = None,
252
260
  print_trace_id: bool = False,
253
261
  print_loaded_report: bool = True,
262
+ reports_path: str | None = None,
254
263
  **kwargs,
255
264
  ):
256
265
  self.bundle = get_custom_bundle(custom_bundle_config)
@@ -275,6 +284,7 @@ class FeaturesEnricher(TransformerMixin):
275
284
 
276
285
  self.passed_features: list[str] = []
277
286
  self.df_with_original_index: pd.DataFrame | None = None
287
+ self._fit_match_search_keys: dict[str, SearchKey] | None = None
278
288
  self.fit_columns_renaming: dict[str, str] | None = None
279
289
  self.country_added = False
280
290
  self.fit_generated_features: list[str] = []
@@ -308,6 +318,7 @@ class FeaturesEnricher(TransformerMixin):
308
318
  self.relevant_data_sources: pd.DataFrame = self.EMPTY_DATA_SOURCES
309
319
  self._relevant_data_sources_wo_links: pd.DataFrame = self.EMPTY_DATA_SOURCES
310
320
  self.metrics: pd.DataFrame | None = None
321
+ self.metrics_metric_name: str | None = None
311
322
  self.feature_names_ = []
312
323
  self.external_source_feature_names = []
313
324
  self.feature_importances_ = []
@@ -317,6 +328,9 @@ class FeaturesEnricher(TransformerMixin):
317
328
  self.disable_force_downsampling = disable_force_downsampling
318
329
  self.print_trace_id = print_trace_id
319
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
320
334
 
321
335
  if search_id:
322
336
  search_task = SearchTask(search_id, rest_client=self.rest_client, logger=self.logger)
@@ -1136,18 +1150,9 @@ class FeaturesEnricher(TransformerMixin):
1136
1150
  uplift_perc = None
1137
1151
  enriched_estimator = None
1138
1152
  ensemble_score_column = self._get_ensemble_score_column(fitting_X, fitting_enriched_X)
1139
- if set(fitting_X.columns) != set(fitting_enriched_X.columns):
1140
- if self.baseline_score_column is not None and ensemble_score_column is not None:
1141
- self.logger.info(
1142
- f"Only ensemble model returned; calculate enriched {metric} via roc_auc "
1143
- f"on ensemble score column: {ensemble_score_column}"
1144
- )
1145
- else:
1146
- self.logger.info(
1147
- f"Calculate enriched {metric} on train combined "
1148
- f"features: {fitting_enriched_X.columns.to_list()}"
1149
- )
1150
- enriched_estimator = EstimatorWrapper.create(
1153
+
1154
+ def evaluate_enriched(score_column, description: str):
1155
+ wrapper = EstimatorWrapper.create(
1151
1156
  estimator,
1152
1157
  self.logger,
1153
1158
  model_task_type,
@@ -1159,22 +1164,48 @@ class FeaturesEnricher(TransformerMixin):
1159
1164
  text_features=text_features,
1160
1165
  has_time=has_time,
1161
1166
  )
1162
- enriched_cv_result = enriched_estimator.cross_val_predict(
1163
- 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
1164
1169
  )
1165
- enriched_metric = enriched_cv_result.get_display_metric()
1166
- updating_shaps = enriched_cv_result.shap_values
1167
-
1168
- if enriched_metric is None:
1169
- self.logger.warning(
1170
- f"Enriched {metric} on train combined features is None (maybe all features was removed)"
1171
- )
1172
- enriched_estimator = None
1173
- else:
1174
- self.logger.info(f"Enriched {metric} on train combined features: {enriched_metric}")
1175
- if baseline_metric is not None and enriched_metric is not None:
1176
- uplift = (enriched_cv_result.metric - baseline_cv_result.metric) * multiplier
1177
- 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")
1178
1209
 
1179
1210
  train_metrics = {
1180
1211
  self.bundle.get("quality_metrics_segment_header"): self.bundle.get(
@@ -1321,6 +1352,7 @@ class FeaturesEnricher(TransformerMixin):
1321
1352
  elif uplift_col in metrics_df.columns and (metrics_df[uplift_col] < 0).any():
1322
1353
  self.logger.warning("Uplift is negative")
1323
1354
 
1355
+ self.metrics_metric_name = metric
1324
1356
  return metrics_df
1325
1357
  except Exception as e:
1326
1358
  error_message = "Failed to calculate metrics" + (
@@ -1759,10 +1791,12 @@ class FeaturesEnricher(TransformerMixin):
1759
1791
  rows_to_drop: pd.DataFrame | None = None,
1760
1792
  left_columns: list[str] | None = None,
1761
1793
  ) -> tuple[pd.DataFrame, list[str], dict[str, SearchKey]] | None:
1762
- if self.df_with_original_index is None or self._search_task is None:
1794
+ if self._search_task is None:
1763
1795
  return None
1764
1796
  if self.fit_columns_renaming is None or self.fit_search_keys is None:
1765
1797
  return None
1798
+ if self.df_with_original_index is None and not self.__has_persisted_match_hashes():
1799
+ return None
1766
1800
 
1767
1801
  fit_features = self._search_task.get_all_initial_raw_features(self._get_trace_id(), metrics_calculation=True)
1768
1802
  if fit_features is None or len(fit_features) == 0:
@@ -1775,6 +1809,12 @@ class FeaturesEnricher(TransformerMixin):
1775
1809
  ]
1776
1810
  self.logger.info(f"After dropping target outliers size: {len(fit_features)}")
1777
1811
 
1812
+ generated_features = [self.fit_columns_renaming.get(c, c) for c in (self.fit_generated_features or [])]
1813
+ search_keys = {self.fit_columns_renaming.get(k, k): v for k, v in self.fit_search_keys.items()}
1814
+
1815
+ if self.df_with_original_index is None:
1816
+ return fit_features.rename(columns=self.fit_columns_renaming), generated_features, search_keys
1817
+
1778
1818
  left = self.df_with_original_index
1779
1819
  if left_columns is not None:
1780
1820
  left = left.loc[:, left_columns]
@@ -1791,10 +1831,80 @@ class FeaturesEnricher(TransformerMixin):
1791
1831
  drop_system_record_id=False,
1792
1832
  )
1793
1833
  enriched_Xy = enriched_Xy.rename(columns=self.fit_columns_renaming)
1794
- generated_features = [self.fit_columns_renaming.get(c, c) for c in (self.fit_generated_features or [])]
1795
- search_keys = {self.fit_columns_renaming.get(k, k): v for k, v in self.fit_search_keys.items()}
1796
1834
  return enriched_Xy, generated_features, search_keys
1797
1835
 
1836
+ def __has_persisted_match_hashes(self) -> bool:
1837
+ if self.add_info is None:
1838
+ return False
1839
+ entity_ids = self.add_info.transform_match_entity_ids
1840
+ hashes = self.add_info.transform_match_hashes
1841
+ return bool(entity_ids) and bool(hashes) and len(entity_ids) == len(hashes)
1842
+
1843
+ def __reuse_match_columns(self, df: pd.DataFrame, search_keys: dict[str, SearchKey]) -> list[str] | None:
1844
+ if self._search_task is None:
1845
+ return None
1846
+ features_for_transform = [
1847
+ f
1848
+ for f in self._search_task.get_features_for_transform()
1849
+ if f not in get_derived_search_key_columns(search_keys)
1850
+ ]
1851
+ renaming = self.fit_columns_renaming or {}
1852
+ reverse = {v: k for k, v in renaming.items()}
1853
+ resolved: list[str] = []
1854
+ for feature in features_for_transform:
1855
+ col = feature if feature in df.columns else reverse.get(feature)
1856
+ if col is None or col not in df.columns:
1857
+ self.logger.info(f"Skip persisting transform match hashes: missing column {feature}")
1858
+ return None
1859
+ if col not in search_keys:
1860
+ resolved.append(col)
1861
+ match_cols = self.__transform_match_columns(search_keys, resolved)
1862
+ missing = [c for c in match_cols if c not in df.columns]
1863
+ if missing:
1864
+ self.logger.info(f"Skip persisting transform match hashes: missing columns {missing}")
1865
+ return None
1866
+ return match_cols
1867
+
1868
+ def __persist_transform_match_hashes(self) -> None:
1869
+ if self.add_info is None or self.df_with_original_index is None or self._search_task is None:
1870
+ return
1871
+ if ENTITY_SYSTEM_RECORD_ID not in self.df_with_original_index.columns:
1872
+ return
1873
+ match_cols = self.__reuse_match_columns(
1874
+ self.df_with_original_index,
1875
+ self._fit_match_search_keys or self.fit_search_keys or {},
1876
+ )
1877
+ if not match_cols:
1878
+ return
1879
+ match_hash = pd.util.hash_pandas_object(self.df_with_original_index[match_cols], index=False).astype("float64")
1880
+ pairs = pd.DataFrame(
1881
+ {
1882
+ ENTITY_SYSTEM_RECORD_ID: self.df_with_original_index[ENTITY_SYSTEM_RECORD_ID].to_numpy(),
1883
+ MATCH_HASH: match_hash.to_numpy(),
1884
+ }
1885
+ )
1886
+ pairs = self.__dedupe_on(pairs, [ENTITY_SYSTEM_RECORD_ID])
1887
+ pairs = self.__dedupe_on(pairs, [MATCH_HASH])
1888
+ self.add_info.transform_match_entity_ids = [float(v) for v in pairs[ENTITY_SYSTEM_RECORD_ID].to_numpy()]
1889
+ self.add_info.transform_match_hashes = [float(v) for v in pairs[MATCH_HASH].to_numpy()]
1890
+ try:
1891
+ payload = pydantic_dump_method(self.add_info)(exclude_none=True)
1892
+ payload.pop("autodetected_search_keys", None)
1893
+ self._search_task.update_add_info(self._get_trace_id(), payload)
1894
+ except Exception:
1895
+ self.logger.exception("Failed to persist transform match hashes in add_info")
1896
+
1897
+ def __persisted_hash_pairs(self) -> pd.DataFrame | None:
1898
+ if not self.__has_persisted_match_hashes():
1899
+ return None
1900
+ pairs = pd.DataFrame(
1901
+ {
1902
+ ENTITY_SYSTEM_RECORD_ID: self.add_info.transform_match_entity_ids,
1903
+ MATCH_HASH: self.add_info.transform_match_hashes,
1904
+ }
1905
+ )
1906
+ return self.__dedupe_on(pairs, [MATCH_HASH])
1907
+
1798
1908
  @staticmethod
1799
1909
  def __transform_match_columns(search_keys: dict[str, SearchKey], features_for_transform: list[str]) -> list[str]:
1800
1910
  return sorted(dict.fromkeys(list(search_keys.keys()) + list(features_for_transform)))
@@ -1831,41 +1941,50 @@ class FeaturesEnricher(TransformerMixin):
1831
1941
  feature_cols: list[str],
1832
1942
  enriched_Xy: pd.DataFrame,
1833
1943
  ) -> pd.DataFrame | None:
1834
- if self.df_with_original_index is None or enriched_Xy is None or len(enriched_Xy) == 0:
1944
+ if enriched_Xy is None or len(enriched_Xy) == 0:
1835
1945
  return None
1836
1946
  if not match_columns_hashed or not feature_cols:
1837
1947
  return None
1838
1948
  if ENTITY_SYSTEM_RECORD_ID not in enriched_Xy.columns:
1839
1949
  return None
1840
- if ENTITY_SYSTEM_RECORD_ID not in self.df_with_original_index.columns:
1841
- return None
1842
1950
 
1843
1951
  ads_cols = [c for c in feature_cols if c in enriched_Xy.columns]
1844
1952
  if not ads_cols:
1845
1953
  return None
1846
1954
 
1847
- key_cols = [c for c in match_columns_hashed if c in self.df_with_original_index.columns]
1848
- if len(key_cols) != len(match_columns_hashed):
1849
- return None
1850
-
1851
1955
  ads = self.__dedupe_on(enriched_Xy.loc[:, [ENTITY_SYSTEM_RECORD_ID] + ads_cols], [ENTITY_SYSTEM_RECORD_ID])
1852
1956
  covered_ids = ads[ENTITY_SYSTEM_RECORD_ID].to_numpy()
1853
1957
 
1854
- fit_keys = self.df_with_original_index.loc[:, key_cols + [ENTITY_SYSTEM_RECORD_ID]]
1855
- covered_mask = fit_keys[ENTITY_SYSTEM_RECORD_ID].isin(covered_ids)
1856
- if not covered_mask.all():
1857
- fit_keys = fit_keys.loc[covered_mask]
1858
- fit_keys = self.__dedupe_on(fit_keys, [ENTITY_SYSTEM_RECORD_ID])
1859
- if len(fit_keys) == 0:
1958
+ if self.df_with_original_index is not None and ENTITY_SYSTEM_RECORD_ID in self.df_with_original_index.columns:
1959
+ key_cols = [c for c in match_columns_hashed if c in self.df_with_original_index.columns]
1960
+ if len(key_cols) != len(match_columns_hashed):
1961
+ return None
1962
+ fit_keys = self.df_with_original_index.loc[:, key_cols + [ENTITY_SYSTEM_RECORD_ID]]
1963
+ covered_mask = fit_keys[ENTITY_SYSTEM_RECORD_ID].isin(covered_ids)
1964
+ if not covered_mask.all():
1965
+ fit_keys = fit_keys.loc[covered_mask]
1966
+ fit_keys = self.__dedupe_on(fit_keys, [ENTITY_SYSTEM_RECORD_ID])
1967
+ if len(fit_keys) == 0:
1968
+ return None
1969
+ aligned = self.__align_match_frame(fit_keys, df_transform, match_columns_hashed)
1970
+ if aligned is None:
1971
+ return None
1972
+ match_hash = pd.util.hash_pandas_object(aligned, index=False).astype("float64")
1973
+ lookup = ads.set_index(ENTITY_SYSTEM_RECORD_ID).reindex(fit_keys[ENTITY_SYSTEM_RECORD_ID].to_numpy())
1974
+ lookup[MATCH_HASH] = match_hash.to_numpy()
1975
+ lookup.reset_index(drop=True, inplace=True)
1976
+ return self.__dedupe_on(lookup, [MATCH_HASH])
1977
+
1978
+ pairs = self.__persisted_hash_pairs()
1979
+ if pairs is None:
1860
1980
  return None
1861
-
1862
- aligned = self.__align_match_frame(fit_keys, df_transform, match_columns_hashed)
1863
- if aligned is None:
1981
+ covered_mask = pairs[ENTITY_SYSTEM_RECORD_ID].isin(covered_ids)
1982
+ if not covered_mask.all():
1983
+ pairs = pairs.loc[covered_mask]
1984
+ if len(pairs) == 0:
1864
1985
  return None
1865
-
1866
- match_hash = pd.util.hash_pandas_object(aligned, index=False).astype("float64")
1867
- lookup = ads.set_index(ENTITY_SYSTEM_RECORD_ID).reindex(fit_keys[ENTITY_SYSTEM_RECORD_ID].to_numpy())
1868
- lookup[MATCH_HASH] = match_hash.to_numpy()
1986
+ lookup = ads.set_index(ENTITY_SYSTEM_RECORD_ID).reindex(pairs[ENTITY_SYSTEM_RECORD_ID].to_numpy())
1987
+ lookup[MATCH_HASH] = pairs[MATCH_HASH].to_numpy()
1869
1988
  lookup.reset_index(drop=True, inplace=True)
1870
1989
  return self.__dedupe_on(lookup, [MATCH_HASH])
1871
1990
 
@@ -2135,6 +2254,69 @@ class FeaturesEnricher(TransformerMixin):
2135
2254
  column = new_external[0]
2136
2255
  return column if self._is_ensemble_feature(column) else None
2137
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
+
2138
2320
  def _is_ensemble_feature(self, column_name: str) -> bool:
2139
2321
  renaming = self.fit_columns_renaming or {}
2140
2322
  aliases = self._column_name_aliases([column_name], renaming)
@@ -4034,8 +4216,10 @@ if response.status_code == 200:
4034
4216
  self._search_task = None
4035
4217
  self.warning_counter.reset()
4036
4218
  self.df_with_original_index = None
4219
+ self._fit_match_search_keys = None
4037
4220
  self.__cached_sampled_datasets = dict()
4038
4221
  self.metrics = None
4222
+ self.metrics_metric_name = None
4039
4223
  self.fit_columns_renaming = None
4040
4224
  self.fit_dropped_features = set()
4041
4225
  self.fit_generated_features = []
@@ -4280,6 +4464,8 @@ if response.status_code == 200:
4280
4464
 
4281
4465
  # TODO check that this is correct for enrichment
4282
4466
  self.df_with_original_index = df.copy()
4467
+ # Search keys at this point are pre-explode; transform hashes the same grain.
4468
+ self._fit_match_search_keys = dict(self.fit_search_keys)
4283
4469
  # TODO check maybe need to drop _time column from df_with_original_index
4284
4470
 
4285
4471
  df, unnest_search_keys = self._explode_multiple_search_keys(df, self.fit_search_keys, self.fit_columns_renaming)
@@ -4443,6 +4629,8 @@ if response.status_code == 200:
4443
4629
  raise e
4444
4630
 
4445
4631
  self._search_task.poll_result(self._get_trace_id(), quiet=True)
4632
+ self.__persist_transform_match_hashes()
4633
+ self.search_duration_seconds = time.time() - start_time
4446
4634
 
4447
4635
  seconds_left = time.time() - start_time
4448
4636
  progress = SearchProgress(97.0, ProgressStage.GENERATING_REPORT, seconds_left)
@@ -5849,6 +6037,15 @@ if response.status_code == 200:
5849
6037
 
5850
6038
  def __show_report_button(self, display_id: str | None = None, display_handle=None):
5851
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
+
5852
6049
  eval_sets_drift_df = self._get_eval_sets_drift_summary()
5853
6050
 
5854
6051
  return prepare_and_show_report(
@@ -395,6 +395,10 @@ class AddInfo(BaseModel):
395
395
  true_one_hot_groups: Optional[Dict[str, List[str]]] = None
396
396
  pseudo_one_hot_groups: Optional[Dict[str, List[str]]] = None
397
397
  autodetected_search_keys: Optional[Dict[str, SearchKey]] = None
398
+ # Unique pre-explode match hashes for transform reuse after restore (search_id).
399
+ # Parallel lists: entity_system_record_id[i] <-> hash[i].
400
+ transform_match_entity_ids: Optional[List[float]] = None
401
+ transform_match_hashes: Optional[List[float]] = None
398
402
 
399
403
 
400
404
  class AdsHintType(str, Enum):
@@ -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)