upgini 1.2.167a4220.dev7__tar.gz → 1.2.167a4220.dev9__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 (94) hide show
  1. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev9}/PKG-INFO +1 -1
  2. upgini-1.2.167a4220.dev9/src/upgini/__about__.py +1 -0
  3. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev9}/src/upgini/features_enricher.py +84 -65
  4. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev9}/src/upgini/metadata.py +2 -0
  5. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev9}/src/upgini/report/assemble.py +179 -40
  6. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev9}/src/upgini/report/data.py +6 -0
  7. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev9}/src/upgini/report/html.py +33 -5
  8. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev9}/src/upgini/report/template.html +7 -4
  9. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev9}/src/upgini/search_task.py +25 -0
  10. upgini-1.2.167a4220.dev7/src/upgini/__about__.py +0 -1
  11. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev9}/.gitignore +0 -0
  12. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev9}/LICENSE +0 -0
  13. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev9}/README.md +0 -0
  14. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev9}/pyproject.toml +0 -0
  15. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev9}/src/upgini/__init__.py +0 -0
  16. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev9}/src/upgini/ads.py +0 -0
  17. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev9}/src/upgini/ads_management/__init__.py +0 -0
  18. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev9}/src/upgini/ads_management/ads_manager.py +0 -0
  19. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev9}/src/upgini/autofe/__init__.py +0 -0
  20. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev9}/src/upgini/autofe/all_operators.py +0 -0
  21. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev9}/src/upgini/autofe/binary.py +0 -0
  22. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev9}/src/upgini/autofe/date.py +0 -0
  23. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev9}/src/upgini/autofe/feature.py +0 -0
  24. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev9}/src/upgini/autofe/groupby.py +0 -0
  25. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev9}/src/upgini/autofe/operand.py +0 -0
  26. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev9}/src/upgini/autofe/operator.py +0 -0
  27. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev9}/src/upgini/autofe/timeseries/__init__.py +0 -0
  28. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev9}/src/upgini/autofe/timeseries/base.py +0 -0
  29. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev9}/src/upgini/autofe/timeseries/cross.py +0 -0
  30. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev9}/src/upgini/autofe/timeseries/delta.py +0 -0
  31. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev9}/src/upgini/autofe/timeseries/lag.py +0 -0
  32. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev9}/src/upgini/autofe/timeseries/numpy_kernels.py +0 -0
  33. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev9}/src/upgini/autofe/timeseries/roll.py +0 -0
  34. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev9}/src/upgini/autofe/timeseries/trend.py +0 -0
  35. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev9}/src/upgini/autofe/timeseries/volatility.py +0 -0
  36. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev9}/src/upgini/autofe/unary.py +0 -0
  37. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev9}/src/upgini/autofe/utils.py +0 -0
  38. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev9}/src/upgini/autofe/vector.py +0 -0
  39. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev9}/src/upgini/data_source/__init__.py +0 -0
  40. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev9}/src/upgini/data_source/data_source_publisher.py +0 -0
  41. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev9}/src/upgini/dataset.py +0 -0
  42. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev9}/src/upgini/errors.py +0 -0
  43. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev9}/src/upgini/http.py +0 -0
  44. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev9}/src/upgini/mdc/__init__.py +0 -0
  45. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev9}/src/upgini/mdc/context.py +0 -0
  46. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev9}/src/upgini/metrics.py +0 -0
  47. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev9}/src/upgini/normalizer/__init__.py +0 -0
  48. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev9}/src/upgini/normalizer/normalize_utils.py +0 -0
  49. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev9}/src/upgini/report/__init__.py +0 -0
  50. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev9}/src/upgini/report/stats.py +0 -0
  51. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev9}/src/upgini/resource_bundle/__init__.py +0 -0
  52. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev9}/src/upgini/resource_bundle/exceptions.py +0 -0
  53. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev9}/src/upgini/resource_bundle/strings.properties +0 -0
  54. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev9}/src/upgini/resource_bundle/strings_widget.properties +0 -0
  55. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev9}/src/upgini/sampler/__init__.py +0 -0
  56. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev9}/src/upgini/sampler/base.py +0 -0
  57. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev9}/src/upgini/sampler/random_under_sampler.py +0 -0
  58. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev9}/src/upgini/sampler/utils.py +0 -0
  59. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev9}/src/upgini/spinner.py +0 -0
  60. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev9}/src/upgini/utils/Roboto-Regular.ttf +0 -0
  61. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev9}/src/upgini/utils/__init__.py +0 -0
  62. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev9}/src/upgini/utils/base_search_key_detector.py +0 -0
  63. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev9}/src/upgini/utils/blocked_time_series.py +0 -0
  64. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev9}/src/upgini/utils/config.py +0 -0
  65. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev9}/src/upgini/utils/country_utils.py +0 -0
  66. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev9}/src/upgini/utils/cpu_utils.py +0 -0
  67. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev9}/src/upgini/utils/custom_loss_utils.py +0 -0
  68. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev9}/src/upgini/utils/cv_utils.py +0 -0
  69. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev9}/src/upgini/utils/datetime_utils.py +0 -0
  70. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev9}/src/upgini/utils/deduplicate_utils.py +0 -0
  71. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev9}/src/upgini/utils/display_utils.py +0 -0
  72. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev9}/src/upgini/utils/email_utils.py +0 -0
  73. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev9}/src/upgini/utils/fallback_progress_bar.py +0 -0
  74. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev9}/src/upgini/utils/feature_info.py +0 -0
  75. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev9}/src/upgini/utils/features_validator.py +0 -0
  76. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev9}/src/upgini/utils/format.py +0 -0
  77. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev9}/src/upgini/utils/hash_utils.py +0 -0
  78. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev9}/src/upgini/utils/ip_utils.py +0 -0
  79. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev9}/src/upgini/utils/mstats.py +0 -0
  80. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev9}/src/upgini/utils/one_hot_encoder.py +0 -0
  81. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev9}/src/upgini/utils/phone_utils.py +0 -0
  82. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev9}/src/upgini/utils/postal_code_utils.py +0 -0
  83. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev9}/src/upgini/utils/progress_bar.py +0 -0
  84. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev9}/src/upgini/utils/psi.py +0 -0
  85. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev9}/src/upgini/utils/pyarrow_utils.py +0 -0
  86. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev9}/src/upgini/utils/sample_utils.py +0 -0
  87. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev9}/src/upgini/utils/search_key_derivations.py +0 -0
  88. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev9}/src/upgini/utils/sklearn_ext.py +0 -0
  89. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev9}/src/upgini/utils/sort.py +0 -0
  90. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev9}/src/upgini/utils/target_utils.py +0 -0
  91. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev9}/src/upgini/utils/track_info.py +0 -0
  92. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev9}/src/upgini/utils/ts_utils.py +0 -0
  93. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev9}/src/upgini/utils/warning_counter.py +0 -0
  94. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev9}/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.167a4220.dev7
3
+ Version: 1.2.167a4220.dev9
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.dev9"
@@ -64,6 +64,7 @@ from upgini.metadata import (
64
64
  FeaturesMetadataV2,
65
65
  FileColumnMeaningType,
66
66
  FileColumnMetadata,
67
+ GeneratedFeatureMetadata,
67
68
  ModelTaskType,
68
69
  RuntimeParameters,
69
70
  SearchKey,
@@ -98,7 +99,13 @@ from upgini.utils.deduplicate_utils import (
98
99
  clean_full_duplicates,
99
100
  remove_fintech_duplicates,
100
101
  )
101
- from upgini.report.assemble import assemble_report_data, autofe_rows_from_description, build_search_results
102
+ from upgini.report.assemble import (
103
+ _generated_feature_names,
104
+ _parse_generated_feature,
105
+ assemble_report_data,
106
+ autofe_rows_from_description,
107
+ build_search_results,
108
+ )
102
109
  from upgini.report.html import generate_html_report
103
110
  from upgini.report.stats import make_report_frame
104
111
  from upgini.utils.display_utils import (
@@ -2288,29 +2295,31 @@ class FeaturesEnricher(TransformerMixin):
2288
2295
  return self._get_single_ensemble_score_name() is not None
2289
2296
 
2290
2297
  def _get_single_ensemble_score_name(self) -> str | None:
2298
+ if len(self._ensemble_generated_metadata()) != 1:
2299
+ return None
2291
2300
  selected = list(self.feature_names_ or [])
2292
2301
  ensemble = [name for name in selected if self._is_ensemble_feature(name)]
2293
2302
  if len(ensemble) != 1:
2294
2303
  return None
2295
- renaming = self.fit_columns_renaming or {}
2296
- allowed = self._column_name_aliases([ensemble[0]], renaming) | self._etalon_feature_aliases()
2297
- if any(name not in allowed for name in selected):
2304
+ if any(self._is_extra_enriched_feature(name) for name in selected):
2298
2305
  return None
2299
2306
  return ensemble[0]
2300
2307
 
2301
- def _etalon_feature_aliases(self) -> set[str]:
2308
+ def _is_extra_enriched_feature(self, name: str) -> bool:
2309
+ if self._is_ensemble_feature(name):
2310
+ return False
2302
2311
  renaming = self.fit_columns_renaming or {}
2303
- names: set[str] = set()
2304
- if self._search_task is not None:
2305
- names.update(
2306
- meta.name
2307
- for meta in (self._search_task.get_all_features_metadata_v2() or [])
2308
- if meta.source == "etalon"
2309
- )
2310
- if isinstance(self.X, pd.DataFrame):
2311
- generated = self._column_name_aliases(self.fit_generated_features or [], renaming)
2312
- names.update(column for column in self.X.columns if column not in generated)
2313
- return self._column_name_aliases(names, renaming)
2312
+ aliases = self._column_name_aliases([name], renaming)
2313
+ if aliases & set(self.external_source_feature_names or []):
2314
+ return True
2315
+ if self._search_task is None:
2316
+ return False
2317
+ for meta in self._search_task.get_all_features_metadata_v2() or []:
2318
+ if meta.source == "etalon" or self._is_ensemble_feature(meta.name):
2319
+ continue
2320
+ if aliases & self._column_name_aliases([meta.name], renaming):
2321
+ return True
2322
+ return False
2314
2323
 
2315
2324
  def _reference_rows(self) -> int | None:
2316
2325
  if self.X is None:
@@ -2472,12 +2481,21 @@ class FeaturesEnricher(TransformerMixin):
2472
2481
  if self._search_task is not None:
2473
2482
  features_meta = list(self._search_task.get_all_features_metadata_v2() or [])
2474
2483
  features, sources, model_shap, summary = build_search_results(
2475
- model_names=self._ensemble_model_feature_names(),
2476
2484
  features_meta=features_meta,
2477
- base_columns=self._ensemble_base_columns(),
2478
2485
  is_ensemble=self._is_ensemble_feature,
2486
+ generated_names=self._column_name_aliases(
2487
+ self.fit_generated_features or [], self.fit_columns_renaming or {}
2488
+ ),
2489
+ generated_features=self._search_task.get_all_generated_features() if self._search_task else [],
2490
+ joined_ads_features_count=(
2491
+ self._search_task.get_joined_ads_features_count() if self._search_task else None
2492
+ ),
2493
+ joined_ads_count=self._search_task.get_joined_ads_count() if self._search_task else None,
2494
+ )
2495
+ autofe = autofe_rows_from_description(
2496
+ self.get_autofe_features_description(features_meta=features_meta),
2497
+ self.bundle,
2479
2498
  )
2480
- autofe = autofe_rows_from_description(self.get_autofe_features_description(), self.bundle)
2481
2499
  return features, sources, model_shap, summary, autofe
2482
2500
 
2483
2501
  def _score_report_html(self) -> str | None:
@@ -2495,15 +2513,29 @@ class FeaturesEnricher(TransformerMixin):
2495
2513
  return False
2496
2514
  return bool(aliases & self._column_name_aliases(list(ensemble_names), renaming))
2497
2515
 
2516
+ def _match_autofe_feature_meta(
2517
+ self,
2518
+ generated_meta: GeneratedFeatureMetadata,
2519
+ features_meta: list[FeaturesMetadataV2],
2520
+ used_names: set[str],
2521
+ ) -> FeaturesMetadataV2 | None:
2522
+ available = [meta for meta in features_meta or [] if meta.name not in used_names]
2523
+ by_name = {meta.name: meta for meta in available}
2524
+ for name in _generated_feature_names(generated_meta):
2525
+ matched = by_name.get(name)
2526
+ if matched is not None:
2527
+ return matched
2528
+ return None
2529
+
2498
2530
  def _ensemble_generated_metadata(self):
2499
2531
  autofe_meta = self._search_task.get_autofe_metadata() if self._search_task else None
2500
2532
  if not autofe_meta:
2501
2533
  return []
2502
2534
  matched = []
2503
2535
  for meta in autofe_meta:
2504
- op = EnsembleModel.from_formula(meta.formula.split("(")[0])
2505
- if op is not None:
2506
- matched.append((meta, op))
2536
+ feature = _parse_generated_feature(meta)
2537
+ if feature is not None and isinstance(feature.op, EnsembleModel):
2538
+ matched.append((meta, feature.op))
2507
2539
  return matched
2508
2540
 
2509
2541
  def _ensemble_feature_names_from_metadata(self) -> set[str]:
@@ -2520,25 +2552,6 @@ class FeaturesEnricher(TransformerMixin):
2520
2552
  names.add(meta.alias)
2521
2553
  return names
2522
2554
 
2523
- def _ensemble_base_columns(self) -> list[tuple[str, str]]:
2524
- return [
2525
- (column.original_name, column.hashed_name)
2526
- for meta, _ in self._ensemble_generated_metadata()
2527
- for column in meta.base_columns
2528
- ]
2529
-
2530
- def _ensemble_model_feature_names(self) -> list[str]:
2531
- return sorted(
2532
- {
2533
- original
2534
- for original, hashed in self._ensemble_base_columns()
2535
- if not self._is_ensemble_feature(original) and not self._is_ensemble_feature(hashed)
2536
- }
2537
- )
2538
-
2539
- def _ensemble_model_feature_count(self) -> int | None:
2540
- return len(self._ensemble_model_feature_names()) or None
2541
-
2542
2555
  def _psi_exceeds_threshold(self, psi: float | None, threshold: float) -> bool:
2543
2556
  return psi is not None and not pd.isna(psi) and psi > threshold
2544
2557
 
@@ -5875,6 +5888,7 @@ if response.status_code == 200:
5875
5888
 
5876
5889
  selected_features_meta.sort(key=lambda m: (-m.shap_value, m.name))
5877
5890
  psi_keepers = self._psi_stability_keepers() if self.psi_values is not None else set()
5891
+ single_ensemble = len(self._ensemble_generated_metadata()) == 1
5878
5892
 
5879
5893
  for feature_meta in selected_features_meta:
5880
5894
  original_name = original_names_dict.get(feature_meta.name, feature_meta.name)
@@ -5884,6 +5898,10 @@ if response.status_code == 200:
5884
5898
  ) or original_name in generated_aliases
5885
5899
  is_client_feature = original_name in clients_features_df.columns and not is_generated_feature
5886
5900
 
5901
+ # Model-input ads/AutoFE rows are only for the HTML report; the selected output is the score.
5902
+ if single_ensemble and not is_client_feature and not self._is_ensemble_feature(feature_meta.name):
5903
+ continue
5904
+
5887
5905
  if not is_client_feature and not is_generated_feature:
5888
5906
  self.external_source_feature_names.append(original_name)
5889
5907
 
@@ -5898,8 +5916,6 @@ if response.status_code == 200:
5898
5916
  if original_name in self.unstable_features or feature_meta.name in self.unstable_features:
5899
5917
  continue
5900
5918
 
5901
- # TODO make a decision about selected features based on special flag from mlb
5902
-
5903
5919
  if original_shaps.get(feature_meta.name, 0.0) == 0.0 and not self._should_show_client_feature_in_report(
5904
5920
  is_client_feature, feature_meta.name
5905
5921
  ):
@@ -5950,12 +5966,12 @@ if response.status_code == 200:
5950
5966
  else:
5951
5967
  self.logger.warning("Empty features info")
5952
5968
 
5953
- def get_autofe_features_description(self):
5969
+ def get_autofe_features_description(self, features_meta: list[FeaturesMetadataV2] | None = None):
5954
5970
  try:
5955
5971
  autofe_meta = self._search_task.get_autofe_metadata()
5956
5972
  if autofe_meta is None:
5957
5973
  return None
5958
- if len(self._internal_features_info) != 0:
5974
+ if features_meta is None and len(self._internal_features_info) != 0:
5959
5975
 
5960
5976
  def to_feature_meta(row):
5961
5977
  fm = FeaturesMetadataV2(
@@ -5969,38 +5985,40 @@ if response.status_code == 200:
5969
5985
  return fm
5970
5986
 
5971
5987
  features_meta = self._internal_features_info.apply(to_feature_meta, axis=1).to_list()
5972
- else:
5988
+ elif features_meta is None:
5973
5989
  features_meta = self._search_task.get_all_features_metadata_v2()
5974
5990
 
5975
- def get_feature_by_name(name: str):
5976
- for m in features_meta:
5977
- if m.name == name:
5978
- return m
5979
-
5980
5991
  descriptions = []
5992
+ used_names: set[str] = set()
5981
5993
  for m in autofe_meta:
5982
- orig_to_hashed = {base_column.original_name: base_column.hashed_name for base_column in m.base_columns}
5983
-
5984
- autofe_feature = (
5985
- Feature.from_formula(m.formula)
5986
- .set_display_index(m.display_index)
5987
- .set_alias(m.alias)
5988
- .set_op_params(m.operator_params or {})
5989
- .rename_columns(orig_to_hashed)
5990
- )
5994
+ try:
5995
+ orig_to_hashed = {
5996
+ base_column.original_name: base_column.hashed_name for base_column in m.base_columns
5997
+ }
5998
+ autofe_feature = (
5999
+ Feature.from_formula(m.formula)
6000
+ .set_display_index(m.display_index or None)
6001
+ .set_alias(m.alias)
6002
+ .set_op_params(m.operator_params or {})
6003
+ .rename_columns(orig_to_hashed)
6004
+ )
6005
+ except Exception:
6006
+ self.logger.exception(f"Failed to parse AutoFE formula: {m.formula}")
6007
+ continue
5991
6008
 
5992
6009
  is_ts = isinstance(autofe_feature.op, TimeSeriesBase)
5993
6010
 
5994
6011
  if autofe_feature.op.is_vector and not is_ts:
5995
6012
  continue
5996
6013
 
5997
- description = {}
5998
-
5999
- feature_meta = get_feature_by_name(autofe_feature.get_display_name(shorten=True, unhash=True))
6000
- if feature_meta is None:
6014
+ feature_meta = self._match_autofe_feature_meta(m, features_meta, used_names)
6015
+ if feature_meta is None or not feature_meta.shap_value:
6001
6016
  continue
6017
+
6018
+ description = {}
6002
6019
  description["shap"] = feature_meta.shap_value
6003
- description[self.bundle.get("autofe_descriptions_sources")] = feature_meta.data_source.replace(
6020
+ source = feature_meta.data_source or ""
6021
+ description[self.bundle.get("autofe_descriptions_sources")] = source.replace(
6004
6022
  "AutoFE: features from ", ""
6005
6023
  ).replace("AutoFE: feature from ", "")
6006
6024
  description[self.bundle.get("autofe_descriptions_feature_name")] = feature_meta.name
@@ -6019,6 +6037,7 @@ if response.status_code == 200:
6019
6037
  sorted(autofe_feature.get_all_operand_names())
6020
6038
  )
6021
6039
 
6040
+ used_names.add(feature_meta.name)
6022
6041
  descriptions.append(description)
6023
6042
 
6024
6043
  if len(descriptions) == 0:
@@ -346,6 +346,8 @@ class ProviderTaskMetadataV2(BaseModel):
346
346
  zero_hit_rate_search_keys: Optional[List[str]] = None
347
347
  features_used_for_embeddings: Optional[List[str]] = None
348
348
  shuffle_kfold: Optional[bool] = None
349
+ joined_ads_features_count: Optional[int] = None
350
+ joined_ads_count: Optional[int] = None
349
351
  generated_features: Optional[List[GeneratedFeatureMetadata]] = None
350
352
 
351
353
 
@@ -7,7 +7,9 @@ from typing import Callable, Optional
7
7
  import pandas as pd
8
8
 
9
9
  from upgini.__about__ import __version__
10
- from upgini.metadata import FeaturesMetadataV2
10
+ from upgini.autofe.feature import Feature
11
+ from upgini.autofe.vector import CatboostModel, EnsembleModel, OnnxModel
12
+ from upgini.metadata import BaseColumnMetadata, FeaturesMetadataV2, GeneratedFeatureMetadata
11
13
  from upgini.report.data import (
12
14
  FeatureRow,
13
15
  ModelFeatureShap,
@@ -98,13 +100,23 @@ def assemble_report_data(
98
100
 
99
101
  def build_search_results(
100
102
  *,
101
- model_names: list[str],
102
103
  features_meta: list[FeaturesMetadataV2],
103
- base_columns: list[tuple[str, str]],
104
104
  is_ensemble: Callable[[str], bool],
105
+ generated_names: Optional[set[str]] = None,
106
+ generated_features: Optional[list[GeneratedFeatureMetadata]] = None,
107
+ joined_ads_features_count: Optional[int] = None,
108
+ joined_ads_count: Optional[int] = None,
105
109
  ) -> tuple[list[FeatureRow], list[SourceRow], list[ModelFeatureShap], SearchResultsSummary]:
106
- meta_by_name = _features_meta_index(features_meta, base_columns)
107
- model_rows = [_feature_row(name, meta_by_name.get(name)) for name in model_names]
110
+ generated_names = generated_names or set()
111
+ model_rows: list[FeatureRow] = []
112
+ found_rows: list[FeatureRow] = []
113
+ for meta in features_meta:
114
+ if is_ensemble(meta.name) or not _has_nonzero_shap_value(meta.shap_value):
115
+ continue
116
+ row = _feature_row(meta.name, meta, generated_names)
117
+ model_rows.append(row)
118
+ if meta.source != "etalon":
119
+ found_rows.append(row)
108
120
  model_rows.sort(key=lambda row: (row.shap is None, -abs(row.shap or 0.0), row.name))
109
121
  model_shap = [
110
122
  ModelFeatureShap(
@@ -115,23 +127,16 @@ def build_search_results(
115
127
  )
116
128
  for index, row in enumerate(model_rows, start=1)
117
129
  ]
118
- join_rows = [
119
- _feature_row(meta.name, meta)
120
- for meta in features_meta
121
- if meta.source != "etalon" and not is_ensemble(meta.name)
122
- ]
123
- sources = _source_rows(join_rows)
124
- relevant = sum(1 for row in join_rows if _has_nonzero_shap(row))
125
- contributed = len({(row.provider, row.source) for row in join_rows if row.provider and _has_nonzero_shap(row)})
126
- model_count = len(model_names) or None
127
- stable = sum(1 for row in model_rows if row.psi is not None and row.psi < _STABILITY_PSI)
128
- summary = SearchResultsSummary(
129
- relevant_features=relevant,
130
- model_features=model_count,
131
- data_sources=len(sources),
132
- stable_features_share=(stable / len(model_rows)) if model_rows else None,
133
- contributed_sources=contributed,
134
- stable_features=stable if model_rows else None,
130
+ sources = _source_rows(found_rows)
131
+ summary = _overview_summary(
132
+ features_meta=features_meta,
133
+ model_rows=model_rows,
134
+ found_rows=found_rows,
135
+ sources=sources,
136
+ is_ensemble=is_ensemble,
137
+ generated_features=generated_features or [],
138
+ joined_ads_features_count=joined_ads_features_count,
139
+ joined_ads_count=joined_ads_count,
135
140
  )
136
141
  return model_rows, sources, model_shap, summary
137
142
 
@@ -234,23 +239,15 @@ def _as_int(value) -> Optional[int]:
234
239
  return None if pd.isna(value) else int(value)
235
240
 
236
241
 
237
- def _features_meta_index(
238
- features_meta: list[FeaturesMetadataV2], base_columns: list[tuple[str, str]]
239
- ) -> dict[str, FeaturesMetadataV2]:
240
- index = {meta.name: meta for meta in features_meta}
241
- for original, hashed in base_columns:
242
- matched = index.get(hashed) or index.get(original)
243
- if matched is None:
244
- continue
245
- index[original] = matched
246
- index[hashed] = matched
247
- return index
248
-
249
-
250
- def _feature_row(name: str, meta: Optional[FeaturesMetadataV2]) -> FeatureRow:
242
+ def _feature_row(
243
+ name: str, meta: Optional[FeaturesMetadataV2], generated_names: Optional[set[str]] = None
244
+ ) -> FeatureRow:
251
245
  if meta is None:
252
246
  return FeatureRow(name=name)
253
- info = FeatureInfo.from_metadata(meta, None, meta.source == "etalon", False)
247
+ generated_names = generated_names or set()
248
+ is_generated = name in generated_names or meta.source == "generated"
249
+ is_client = meta.source == "etalon" and not is_generated
250
+ info = FeatureInfo.from_metadata(meta, None, is_client, is_generated)
254
251
  return FeatureRow(
255
252
  name=name,
256
253
  shap=meta.shap_value,
@@ -258,11 +255,20 @@ def _feature_row(name: str, meta: Optional[FeaturesMetadataV2]) -> FeatureRow:
258
255
  drift=meta.drift_score,
259
256
  coverage=meta.hit_rate,
260
257
  provider=info.internal_provider,
261
- source=info.internal_source,
258
+ source=_feature_source(meta, is_generated, info.internal_source),
262
259
  stability_status=_psi_status(meta.psi_value),
263
260
  )
264
261
 
265
262
 
263
+ def _feature_source(meta: FeaturesMetadataV2, is_generated: bool, fallback: str) -> str:
264
+ if is_generated:
265
+ if meta.data_source:
266
+ return meta.data_source
267
+ if meta.data_sources:
268
+ return ", ".join(meta.data_sources)
269
+ return fallback
270
+
271
+
266
272
  def _source_rows(rows: list[FeatureRow]) -> list[SourceRow]:
267
273
  grouped: dict[tuple[str, str], list[FeatureRow]] = {}
268
274
  for row in rows:
@@ -292,5 +298,138 @@ def _psi_status(psi: Optional[float]) -> Optional[str]:
292
298
  return "good"
293
299
 
294
300
 
295
- def _has_nonzero_shap(row: FeatureRow) -> bool:
296
- return row.shap is not None and row.shap != 0
301
+ def _has_nonzero_shap_value(shap: Optional[float]) -> bool:
302
+ return shap is not None and not pd.isna(shap) and shap != 0
303
+
304
+
305
+ def _overview_summary(
306
+ *,
307
+ features_meta: list[FeaturesMetadataV2],
308
+ model_rows: list[FeatureRow],
309
+ found_rows: list[FeatureRow],
310
+ sources: list[SourceRow],
311
+ is_ensemble: Callable[[str], bool],
312
+ generated_features: list[GeneratedFeatureMetadata],
313
+ joined_ads_features_count: Optional[int],
314
+ joined_ads_count: Optional[int],
315
+ ) -> SearchResultsSummary:
316
+ top_level = _unique_fold_model_columns(generated_features)
317
+ features_by_name = {meta.name: meta for meta in features_meta}
318
+ autofe_by_name = _autofe_generated_by_name(generated_features, is_ensemble)
319
+ if top_level:
320
+ counts = {"external": 0, "original": 0, "autofe": 0}
321
+ ads_ids: set[str] = set()
322
+ psis: list[Optional[float]] = []
323
+ for column in top_level:
324
+ kind = _top_level_kind(column, features_by_name, autofe_by_name, is_ensemble)
325
+ counts[kind] += 1
326
+ ads_ids.update(_leaf_ads_ids(column, autofe_by_name))
327
+ meta = features_by_name.get(column.hashed_name) or features_by_name.get(column.original_name)
328
+ psis.append(None if meta is None else meta.psi_value)
329
+ model_features = len(top_level)
330
+ external, original, autofe = counts["external"], counts["original"], counts["autofe"]
331
+ contributed = len(ads_ids)
332
+ else:
333
+ model_features = len(model_rows) or None
334
+ external = original = autofe = None
335
+ contributed = len({(row.provider, row.source) for row in found_rows if row.provider})
336
+ psis = [row.psi for row in model_rows]
337
+ share, stable, psi_count = _stability_from_psi(psis)
338
+ return SearchResultsSummary(
339
+ relevant_features=len(found_rows),
340
+ features_found=joined_ads_features_count if joined_ads_features_count is not None else len(found_rows),
341
+ model_features=model_features,
342
+ external_features=external,
343
+ original_features=original,
344
+ autofe_features=autofe,
345
+ data_sources=len(sources),
346
+ joined_sources=joined_ads_count if joined_ads_count is not None else len(sources),
347
+ stable_features_share=share,
348
+ contributed_sources=contributed,
349
+ stable_features=stable,
350
+ psi_features=psi_count,
351
+ )
352
+
353
+
354
+ def _unique_fold_model_columns(generated_features: list[GeneratedFeatureMetadata]) -> list[BaseColumnMetadata]:
355
+ unique: dict[str, BaseColumnMetadata] = {}
356
+ for meta in generated_features:
357
+ feature = _parse_generated_feature(meta)
358
+ if feature is None or not isinstance(feature.op, (CatboostModel, OnnxModel)):
359
+ continue
360
+ for column in meta.base_columns:
361
+ unique.setdefault(column.hashed_name or column.original_name, column)
362
+ return list(unique.values())
363
+
364
+
365
+ def _autofe_generated_by_name(
366
+ generated_features: list[GeneratedFeatureMetadata], is_ensemble: Callable[[str], bool]
367
+ ) -> dict[str, GeneratedFeatureMetadata]:
368
+ by_name: dict[str, GeneratedFeatureMetadata] = {}
369
+ for meta in generated_features:
370
+ feature = _parse_generated_feature(meta)
371
+ if feature is None or isinstance(feature.op, (CatboostModel, EnsembleModel, OnnxModel)):
372
+ continue
373
+ name = feature.get_display_name(shorten=True, unhash=True, cache=False)
374
+ if name and not is_ensemble(name):
375
+ by_name.setdefault(name, meta)
376
+ return by_name
377
+
378
+
379
+ def _parse_generated_feature(meta: GeneratedFeatureMetadata) -> Feature | None:
380
+ try:
381
+ parsed = Feature.from_formula(meta.formula)
382
+ if not isinstance(parsed, Feature):
383
+ return None
384
+ orig_to_hashed = {column.original_name: column.hashed_name for column in meta.base_columns}
385
+ return (
386
+ parsed.set_display_index(meta.display_index or None)
387
+ .set_alias(meta.alias)
388
+ .set_op_params(meta.operator_params or {})
389
+ .rename_columns(orig_to_hashed)
390
+ )
391
+ except Exception:
392
+ return None
393
+
394
+
395
+ def _generated_feature_names(meta: GeneratedFeatureMetadata) -> set[str]:
396
+ feature = _parse_generated_feature(meta)
397
+ if feature is None:
398
+ return set()
399
+ return {feature.get_display_name(shorten=True, unhash=True, cache=False)}
400
+
401
+
402
+ def _top_level_kind(
403
+ column: BaseColumnMetadata,
404
+ features_by_name: dict[str, FeaturesMetadataV2],
405
+ autofe_by_name: dict[str, GeneratedFeatureMetadata],
406
+ is_ensemble: Callable[[str], bool],
407
+ ) -> str:
408
+ names = [column.hashed_name, column.original_name]
409
+ if any(name in autofe_by_name for name in names if name):
410
+ return "autofe"
411
+ meta = next((features_by_name[name] for name in names if name in features_by_name), None)
412
+ if meta is not None and not is_ensemble(meta.name):
413
+ if meta.source == "generated":
414
+ return "autofe"
415
+ if meta.source == "ads":
416
+ return "external"
417
+ if meta.source == "etalon":
418
+ return "original"
419
+ if column.ads_definition_id:
420
+ return "external"
421
+ return "original"
422
+
423
+
424
+ def _leaf_ads_ids(column: BaseColumnMetadata, autofe_by_name: dict[str, GeneratedFeatureMetadata]) -> set[str]:
425
+ autofe = autofe_by_name.get(column.hashed_name) or autofe_by_name.get(column.original_name)
426
+ leaves = autofe.base_columns if autofe is not None else [column]
427
+ return {leaf.ads_definition_id for leaf in leaves if leaf.ads_definition_id}
428
+
429
+
430
+ def _stability_from_psi(psis: list[Optional[float]]) -> tuple[Optional[float], Optional[int], Optional[int]]:
431
+ values = [psi for psi in psis if psi is not None and not pd.isna(psi)]
432
+ if not values:
433
+ return None, None, None
434
+ stable = sum(1 for psi in values if psi < _STABILITY_PSI)
435
+ return stable / len(values), stable, len(values)
@@ -66,11 +66,17 @@ class SourceRow:
66
66
  @dataclass
67
67
  class SearchResultsSummary:
68
68
  relevant_features: Optional[int] = None
69
+ features_found: Optional[int] = None
69
70
  model_features: Optional[int] = None
71
+ external_features: Optional[int] = None
72
+ original_features: Optional[int] = None
73
+ autofe_features: Optional[int] = None
70
74
  data_sources: Optional[int] = None
75
+ joined_sources: Optional[int] = None
71
76
  stable_features_share: Optional[float] = None
72
77
  contributed_sources: Optional[int] = None
73
78
  stable_features: Optional[int] = None
79
+ psi_features: Optional[int] = None
74
80
 
75
81
 
76
82
  @dataclass
@@ -6,6 +6,7 @@ from typing import Optional
6
6
 
7
7
  from upgini.report.data import FeatureRow, ReportCharts, ReportData, SampleStats, SearchResultsSummary, SourceRow
8
8
  from upgini.report.stats import MEAN_AXIS_PADDING
9
+ from upgini.utils.feature_info import CLIENT_SOURCE, GENERATED_SOURCE
9
10
 
10
11
  _TEMPLATE_PATH = Path(__file__).with_name("template.html")
11
12
  _SAMPLE_COLORS = ("#1645ee", "#20aab4", "#53cc61", "#e19132", "#d45b5b")
@@ -144,22 +145,37 @@ def _summary_cards(summary: SearchResultsSummary) -> list[dict]:
144
145
  if summary.contributed_sources is not None:
145
146
  sources_caption = f"{summary.contributed_sources} contributed"
146
147
  stability_caption = ""
147
- if summary.stable_features is not None and summary.model_features is not None:
148
- stability_caption = f"{summary.stable_features} of {summary.model_features} are stable"
148
+ if summary.stable_features is not None and summary.psi_features is not None:
149
+ stability_caption = f"{summary.stable_features} of {summary.psi_features} are stable"
150
+ features_found = summary.features_found if summary.features_found is not None else summary.relevant_features
151
+ data_sources = summary.joined_sources if summary.joined_sources is not None else summary.data_sources
149
152
  return [
150
- {"label": "Features found", "value": _dash(summary.relevant_features), "caption": ""},
151
- {"label": "Used in model", "value": _dash(summary.model_features), "caption": ""},
152
- {"label": "Data sources", "value": _dash(summary.data_sources), "caption": sources_caption},
153
+ {"label": "Features found", "value": _dash(features_found), "caption": ""},
154
+ {"label": "Used in model", "value": _dash(summary.model_features), "caption": _used_in_model_caption(summary)},
155
+ {"label": "Data sources", "value": _dash(data_sources), "caption": sources_caption},
153
156
  {"label": "Stability", "value": _dash(stability), "caption": stability_caption},
154
157
  ]
155
158
 
156
159
 
160
+ def _used_in_model_caption(summary: SearchResultsSummary) -> str:
161
+ if summary.external_features is None and summary.original_features is None and summary.autofe_features is None:
162
+ return ""
163
+ return " · ".join(
164
+ [
165
+ f"{summary.external_features or 0} external",
166
+ f"{summary.original_features or 0} original",
167
+ f"{summary.autofe_features or 0} AutoFE",
168
+ ]
169
+ )
170
+
171
+
157
172
  def _features_payload(features: list[FeatureRow]) -> list[dict]:
158
173
  return [
159
174
  {
160
175
  "name": row.name,
161
176
  "provider": row.provider,
162
177
  "source": row.source,
178
+ "shapClass": _shap_fill_class(row),
163
179
  "importance": abs(row.shap) if row.shap is not None else 0,
164
180
  "shap": row.shap,
165
181
  "coverage": row.coverage,
@@ -171,6 +187,18 @@ def _features_payload(features: list[FeatureRow]) -> list[dict]:
171
187
  ]
172
188
 
173
189
 
190
+ def _shap_fill_class(row: FeatureRow) -> str:
191
+ source = (row.source or "").lower()
192
+ provider = (row.provider or "").lower()
193
+ if source.startswith("autofe") or provider == "autofe" or source == GENERATED_SOURCE.lower():
194
+ return "autofe"
195
+ if source == CLIENT_SOURCE.lower() or provider == "user":
196
+ return "user"
197
+ if provider in {"", "upgini"}:
198
+ return "upgini"
199
+ return "external"
200
+
201
+
174
202
  def _sources_payload(sources: list[SourceRow]) -> list[dict]:
175
203
  return [
176
204
  {
@@ -7,7 +7,7 @@
7
7
  <style>
8
8
  :root{--ink:#15211f;--muted:#6f7976;--line:#dce4e1;--paper:#fff;--bg:#f3f6f5;--blue:#1645ee;--green:#53cc61;--cyan:#20aab4;--warn:#e19132;--danger:#d45b5b;--shadow:0 18px 60px rgba(18,45,39,.08)}
9
9
  *{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;background:var(--bg);color:var(--ink);font-family:Inter,ui-sans-serif,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;font-size:14px;line-height:1.45}.shell{max-width:1440px;margin:auto;min-height:100vh}.topbar{height:72px;padding:0 42px;display:flex;align-items:center;gap:25px;background:rgba(255,255,255,.92);backdrop-filter:blur(16px);border-bottom:1px solid var(--line);position:sticky;top:0;z-index:20}.brand{display:flex;align-items:center;gap:14px}.wordmark{font-size:25px;font-weight:800;letter-spacing:-.07em;line-height:1;background:linear-gradient(135deg,#0138f8,#5bd357);-webkit-background-clip:text;background-clip:text;color:transparent}.wordmark b{font-weight:850}.wordmark span{font-weight:650}.wordmark.small{font-size:20px;min-width:66px}.brand-divider{width:1px;height:25px;background:var(--line)}.brand-label{font-weight:700;color:#45514f}.top-meta{display:flex;gap:8px;margin-left:auto;align-items:center}.chip{background:#eef2f1;border:1px solid #e0e7e5;border-radius:999px;padding:7px 11px;color:#53605d;font-size:12px}.button{border:0;border-radius:10px;padding:10px 15px;font:inherit;font-weight:700;cursor:pointer;transition:.18s ease}.button:hover{transform:translateY(-1px)}.button.primary{background:var(--ink);color:white}.button.ghost{background:white;border:1px solid var(--line);color:var(--ink)}.layout{display:grid;grid-template-columns:210px minmax(0,1fr);gap:0}.sidenav{position:sticky;top:72px;height:calc(100vh - 72px);padding:30px 22px;border-right:1px solid var(--line);background:#f7f9f8}.nav-title{font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:#98a29f;margin:0 10px 12px}.nav-link{display:flex;align-items:center;gap:10px;color:#66716f;text-decoration:none;padding:10px;border-radius:9px;margin:2px 0;font-weight:600}.nav-link:hover,.nav-link.active{background:white;color:var(--ink);box-shadow:0 4px 18px rgba(25,49,43,.06)}.nav-dot{width:7px;height:7px;border-radius:50%;background:#c4cecb}.nav-link.active .nav-dot{background:linear-gradient(135deg,var(--blue),var(--green))}.run-note{position:absolute;bottom:24px;left:22px;right:22px;padding:14px;background:white;border:1px solid var(--line);border-radius:12px;font-size:11px;color:var(--muted)}.run-note strong{display:block;color:var(--ink);font-size:12px;margin-bottom:4px}.main{min-width:0;padding:38px 44px 80px}.eyebrow{display:flex;align-items:center;gap:8px;text-transform:uppercase;letter-spacing:.12em;color:#74807d;font-size:11px;font-weight:800}.status-dot{width:8px;height:8px;border-radius:50%;background:var(--green);box-shadow:0 0 0 4px #e7f8e9}.hero-title{display:flex;justify-content:space-between;align-items:flex-end;gap:30px;margin:12px 0 28px}.hero-title h1{font-size:36px;line-height:1.08;letter-spacing:-.04em;margin:0;max-width:720px}.hero-title p{color:var(--muted);margin:10px 0 0;max-width:690px}.run-stamp{text-align:right;min-width:190px;color:var(--muted);font-size:12px}.run-stamp strong{display:block;color:var(--ink);font-size:14px;margin-bottom:3px}.metric-hero{position:relative;overflow:hidden;background:#172421;color:white;border-radius:22px;padding:28px 30px;box-shadow:var(--shadow);margin-bottom:18px}.metric-hero:after{content:"";position:absolute;width:400px;height:400px;right:-150px;top:-250px;border-radius:50%;background:radial-gradient(circle,rgba(71,215,117,.24),transparent 68%)}.metric-head{position:relative;z-index:1;display:flex;justify-content:space-between;gap:20px}.metric-tabs{display:flex;gap:5px;background:rgba(255,255,255,.08);border-radius:11px;padding:4px}.metric-tab{border:0;background:transparent;color:#aebbb7;padding:8px 12px;border-radius:8px;font-weight:700;cursor:pointer}.metric-tab.active{background:white;color:#13201e}.scores{position:relative;z-index:1;display:grid;grid-template-columns:1fr 1fr 1.2fr;gap:25px;margin-top:32px;align-items:end}.score-label{text-transform:uppercase;letter-spacing:.11em;color:#8fa09b;font-size:10px;font-weight:800}.score-value{font-size:39px;line-height:1;margin-top:8px;font-variant-numeric:tabular-nums;letter-spacing:-.04em}.uplift .score-value{color:#73e57b}.uplift p{color:#aebcb8;margin:8px 0 0;font-size:12px}.summary-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin:18px 0 34px}.summary{background:white;border:1px solid var(--line);border-radius:15px;padding:17px 18px}.summary .k{color:var(--muted);font-size:11px;text-transform:uppercase;letter-spacing:.09em;font-weight:800}.summary .v{font-size:23px;font-weight:750;margin-top:5px;letter-spacing:-.02em}.summary .d{font-size:11px;color:#88928f;margin-top:4px}.section{scroll-margin-top:94px;margin-top:36px}.section-head{display:flex;align-items:end;justify-content:space-between;gap:20px;margin-bottom:15px}.section-head h2{font-size:22px;letter-spacing:-.025em;margin:0}.section-head p{color:var(--muted);margin:5px 0 0;font-size:12px}.panel{background:white;border:1px solid var(--line);border-radius:17px;box-shadow:0 4px 20px rgba(18,45,39,.035)}.chart-panel{padding:20px}.chart-head{display:flex;justify-content:space-between;align-items:center;gap:16px;margin-bottom:10px}.legend{display:flex;gap:17px;flex-wrap:wrap;font-size:11px;color:var(--muted)}.legend i{display:inline-block;width:9px;height:9px;border-radius:50%;margin-right:6px}.control{border:1px solid var(--line);background:#fafcfb;color:var(--ink);border-radius:9px;padding:8px 10px;font:inherit;font-size:12px}.canvas-wrap{height:280px;position:relative}canvas{width:100%;height:100%}.callout{display:flex;gap:12px;align-items:flex-start;padding:14px 16px;background:#fff8ed;border:1px solid #f1dcc0;border-radius:13px;color:#6b4b20}.callout strong{display:block;color:#4c361a;margin-bottom:2px}.callout-icon{width:27px;height:27px;display:grid;place-items:center;background:#f4c77f;border-radius:8px;font-weight:900;color:#65410c;flex:none}.stability-grid{display:grid;grid-template-columns:minmax(0,1.5fr) minmax(260px,.5fr);gap:14px}.heatmap{overflow:auto;padding:16px}.heat-table{border-collapse:separate;border-spacing:5px;width:100%;font-size:11px}.heat-table th{color:var(--muted);font-weight:600;padding:2px 6px;white-space:nowrap}.heat-table th:first-child{text-align:left}.heat-table td:first-child{font-weight:700;padding-right:12px;white-space:nowrap}.heat{height:30px;min-width:47px;text-align:center;border-radius:6px;font-weight:750}.s1{background:#dbf4de;color:#276b2f}.s2{background:#e9f4cf;color:#526826}.s3{background:#fff0c7;color:#7f641b}.s4{background:#f8d5c8;color:#914634}.stability-side{padding:20px}.donut{width:140px;height:140px;border-radius:50%;margin:8px auto 17px;background:conic-gradient(var(--green) 0 72%,#e6b556 72% 91%,#df7474 91%);position:relative}.donut:after{content:"";position:absolute;inset:25px;background:white;border-radius:50%}.donut-label{position:absolute;inset:0;z-index:1;display:grid;place-items:center;text-align:center;font-weight:800;font-size:21px}.donut-label small{display:block;color:var(--muted);font-size:10px;font-weight:600}.stat-list{display:grid;gap:8px}.stat-row{display:flex;justify-content:space-between;padding-top:8px;border-top:1px solid #edf1ef;color:var(--muted);font-size:12px}.stat-row b{color:var(--ink)}.toolbar{display:flex;gap:9px;flex-wrap:wrap}.search{min-width:230px;border:1px solid var(--line);background:white;border-radius:9px;padding:9px 11px;font:inherit}.table-wrap{overflow:auto}.data-table{width:100%;border-collapse:collapse;min-width:860px}.data-table th{text-align:left;padding:12px 14px;background:#f8faf9;color:#77827f;text-transform:uppercase;letter-spacing:.07em;font-size:10px;border-bottom:1px solid var(--line)}.data-table td{padding:13px 14px;border-bottom:1px solid #edf1ef;font-size:12px}.data-table tr:last-child td{border-bottom:0}.data-table tbody tr:hover{background:#f8fbfa}.feature-name{font-weight:750;color:#25312f}.source-name{color:#74807d}.bar{height:6px;width:90px;background:#edf1ef;border-radius:99px;overflow:hidden}.bar i{height:100%;display:block;background:linear-gradient(90deg,var(--blue),#43c878);border-radius:99px}.tag{display:inline-flex;align-items:center;border-radius:999px;padding:4px 8px;font-size:10px;font-weight:750}.tag.good{background:#e8f7ea;color:#337139}.tag.watch{background:#fff1d9;color:#855d18}.tag.risk{background:#fde7e4;color:#984a40}.source-cards{display:grid;grid-template-columns:repeat(3,1fr);gap:14px}.source-card{padding:19px}.source-top{display:flex;justify-content:space-between;gap:12px}.source-icon{width:38px;height:38px;border-radius:11px;display:grid;place-items:center;background:linear-gradient(135deg,#e8eeff,#e8f9e8);font-weight:850;color:var(--blue)}.source-card h3{font-size:14px;margin:12px 0 4px}.source-card p{font-size:11px;color:var(--muted);margin:0}.source-metrics{display:flex;gap:18px;margin-top:17px;padding-top:13px;border-top:1px solid #edf1ef}.source-metrics b{display:block;font-size:16px}.source-metrics span{color:var(--muted);font-size:10px}.notebook{padding:0;overflow:hidden}.cell-head{background:#f8faf9;border-bottom:1px solid var(--line);padding:10px 14px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:#68736f;font-size:11px}.cell-output{padding:18px;display:flex;align-items:center;gap:25px;flex-wrap:wrap}.cell-quality{display:flex;gap:24px}.cell-quality span{font-size:11px;color:var(--muted)}.cell-quality b{font-size:17px;color:var(--ink);display:block}.footer{text-align:center;color:#8a9592;font-size:11px;padding:40px 0 10px}.empty{display:none;padding:24px;text-align:center;color:var(--muted)}
10
- .sample-table{min-width:700px}.toggle-legend{margin-bottom:10px}.series-toggle{border:0;background:transparent;color:var(--muted);cursor:pointer;font:inherit;font-size:11px;padding:5px 7px;border-radius:6px}.series-toggle:not(.active){opacity:.35;text-decoration:line-through}.threshold-legend{display:flex;gap:8px;justify-content:flex-end;margin-bottom:10px}.shap-panel{padding:16px}.shap-row{display:grid;grid-template-columns:minmax(170px,1fr) 2fr 60px;gap:12px;align-items:center;padding:9px 4px;border-bottom:1px solid #edf1ef}.shap-row:last-child{border:0}.provider{font-size:9px;margin-left:6px;padding:3px 6px;border-radius:99px;border:1px solid var(--line);color:var(--muted)}.shap-track{height:10px;background:#edf1ef;border-radius:99px;overflow:hidden}.shap-fill{display:block;height:100%;border-radius:99px}.shap-fill.upgini{background:#1645ee}.shap-fill.user{background:#20aab4}.shap-fill.autofe{background:#53cc61}.result-tabs{display:flex;gap:4px;margin-bottom:12px}.result-tab{border:1px solid var(--line);background:#eef2f1;padding:9px 13px;border-radius:9px;font:inherit;cursor:pointer;color:var(--muted)}.result-tab.active{background:var(--ink);color:#fff}.result-tab b{font-size:10px;margin-left:5px}.result-pane{display:none}.result-pane.active{display:block}.table-actions{display:flex;gap:8px;justify-content:flex-end;flex-wrap:wrap;margin-bottom:10px}.chart-tooltip{position:absolute;display:none;pointer-events:none;background:#172421;color:white;padding:9px 11px;border-radius:8px;font-size:10px;box-shadow:var(--shadow);white-space:nowrap}.sidenav{overflow:auto}.run-note{position:static;margin-top:22px}.nav-link{font-size:12px;padding:8px 10px}
10
+ .sample-table{min-width:700px}.toggle-legend{margin-bottom:10px}.series-toggle{border:0;background:transparent;color:var(--muted);cursor:pointer;font:inherit;font-size:11px;padding:5px 7px;border-radius:6px}.series-toggle:not(.active){opacity:.35;text-decoration:line-through}.threshold-legend{display:flex;gap:8px;justify-content:flex-end;margin-bottom:10px}.shap-panel{padding:16px}.shap-row{display:grid;grid-template-columns:minmax(170px,1fr) 2fr 60px;gap:12px;align-items:center;padding:9px 4px;border-bottom:1px solid #edf1ef}.shap-row:last-child{border:0}.provider{display:inline-block;font-size:9px;margin-left:6px;padding:3px 7px;border-radius:8px;border:1px solid var(--line);color:var(--muted);line-height:1.3;text-align:center;vertical-align:middle;white-space:normal}.shap-track{height:10px;background:#edf1ef;border-radius:99px;overflow:hidden}.shap-fill{display:block;height:100%;border-radius:99px}.shap-fill.upgini{background:#1645ee}.shap-fill.user{background:#20aab4}.shap-fill.autofe{background:#53cc61}.shap-fill.external{background:#7b5cff}.result-tabs{display:flex;gap:4px;margin-bottom:12px}.result-tab{border:1px solid var(--line);background:#eef2f1;padding:9px 13px;border-radius:9px;font:inherit;cursor:pointer;color:var(--muted)}.result-tab.active{background:var(--ink);color:#fff}.result-tab b{font-size:10px;margin-left:5px}.result-pane{display:none}.result-pane.active{display:block}.table-actions{display:flex;gap:8px;justify-content:flex-end;flex-wrap:wrap;margin-bottom:10px}.chart-tooltip{position:absolute;display:none;pointer-events:none;background:#172421;color:white;padding:9px 11px;border-radius:8px;font-size:10px;box-shadow:var(--shadow);white-space:nowrap}.sidenav{overflow:auto}.run-note{position:static;margin-top:22px}.nav-link{font-size:12px;padding:8px 10px}
11
11
  .metric-sample-table{position:relative;z-index:1;margin-top:26px}.metric-sample-head,.metric-sample-row{display:grid;grid-template-columns:minmax(140px,.9fr) 1fr 1fr .8fr .8fr;gap:18px;align-items:center}.metric-sample-ci{display:block;margin-top:6px;color:#8fa09b;font-size:11px;font-weight:600;font-variant-numeric:tabular-nums}.metric-note{color:#8fa09b;font-size:11px;margin-top:6px}.partner-logo{height:30px;width:auto;display:block}.metric-sample-head{padding:0 14px 8px;color:#8fa09b;text-transform:uppercase;letter-spacing:.11em;font-size:10px;font-weight:800}.metric-sample-row{padding:15px 14px;border-top:1px solid rgba(255,255,255,.12)}.metric-sample-name{display:flex;align-items:center;gap:10px;font-size:16px;font-weight:760}.metric-sample-name i{width:9px;height:9px;border-radius:50%;background:var(--blue);box-shadow:0 0 0 4px rgba(22,69,238,.18)}.metric-sample-row[data-sample="eval_1"] .metric-sample-name i{background:var(--cyan);box-shadow:0 0 0 4px rgba(32,170,180,.18)}.metric-sample-row[data-sample="eval_2"] .metric-sample-name i{background:var(--green);box-shadow:0 0 0 4px rgba(83,204,97,.18)}.metric-sample-name small{display:block;color:#8fa09b;font-size:10px;font-weight:650;margin-top:2px}.metric-sample-value{font-size:27px;line-height:1;font-variant-numeric:tabular-nums;letter-spacing:-.035em}.metric-sample-uplift .metric-sample-value{color:#73e57b}.metric-sample-uplift p{margin:5px 0 0;color:#aebcb8;font-size:11px}.tall-chart{height:340px}.chart-tooltip b{font-size:11px}.chart-tooltip .tip-label{color:#aebbb7}.canvas-wrap canvas{cursor:crosshair}.search-id-chip{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;max-width:360px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
12
12
  @media(max-width:980px){.layout{grid-template-columns:1fr}.sidenav{display:none}.main{padding:28px 22px 70px}.topbar{padding:0 22px}.scores{grid-template-columns:1fr 1fr}.uplift{grid-column:1/-1}.summary-grid{grid-template-columns:1fr 1fr}.stability-grid,.sample-grid{grid-template-columns:1fr}.source-cards{grid-template-columns:1fr 1fr}}
13
13
  @media(max-width:650px){.top-meta .chip,.brand-divider,.brand-label{display:none}.topbar{gap:10px}.button{padding:9px 10px}.hero-title{display:block}.run-stamp{text-align:left;margin-top:16px}.hero-title h1{font-size:30px}.metric-head{display:block}.metric-tabs{margin-top:15px;width:max-content}.summary-grid,.source-cards{grid-template-columns:1fr}.scores{gap:14px}.score-value{font-size:32px}.metric-sample-head{display:none}.metric-sample-row{grid-template-columns:1fr 1fr;gap:14px;padding:16px 2px}.metric-sample-name{grid-column:1/-1}.metric-sample-value{font-size:24px}.metric-sample-row>div:not(.metric-sample-name):before{display:block;color:#8fa09b;text-transform:uppercase;letter-spacing:.08em;font-size:8px;font-weight:800;margin-bottom:6px}.metric-sample-row>div:nth-child(2):before{content:"Baseline"}.metric-sample-row>div:nth-child(3):before{content:"Enriched"}.metric-sample-row>div:nth-child(4):before{content:"Uplift, abs"}.metric-sample-row>div:nth-child(5):before{content:"Uplift, %"}.main{padding-left:15px;padding-right:15px}.cell-output{gap:14px}}
@@ -127,6 +127,9 @@ const REPORT_DATA = __REPORT_DATA__;
127
127
  const pct1 = v => (v == null ? '—' : Number(v).toFixed(1) + '%');
128
128
  const signed3 = v => (v >= 0 ? '+' : '') + Number(v).toFixed(3);
129
129
  const setText = (id, text) => { const el = document.getElementById(id); if (el) el.textContent = text; };
130
+ const providerBadge = provider => provider
131
+ ? `<span class="provider">${String(provider).replace(/,\s*/g, '<br>')}</span>`
132
+ : '';
130
133
  function rgba(hex, alpha) {
131
134
  const h = hex.replace('#', '');
132
135
  const n = parseInt(h.length === 3 ? h.split('').map(x => x + x).join('') : h, 16);
@@ -507,8 +510,8 @@ const REPORT_DATA = __REPORT_DATA__;
507
510
  const maxImportance = Math.max(...features.map(f => f.importance)) || 1;
508
511
  const list = shapAll ? features : features.slice(0, (D.shap && D.shap.topN) || features.length);
509
512
  document.getElementById('shapList').innerHTML = list.map((f, i) =>
510
- `<div class="shap-row"><div><b>#${i + 1} ${f.name}</b><span class="provider">${f.provider}</span></div>` +
511
- `<div class="shap-track"><i class="shap-fill ${f.provider.toLowerCase()}" style="width:${(f.importance / maxImportance * 100).toFixed(2)}%"></i></div>` +
513
+ `<div class="shap-row"><div><b>#${i + 1} ${f.name}</b>${providerBadge(f.provider)}</div>` +
514
+ `<div class="shap-track"><i class="shap-fill ${f.shapClass || 'upgini'}" style="width:${(f.importance / maxImportance * 100).toFixed(2)}%"></i></div>` +
512
515
  `<b>${fmt3(Math.abs(f.shap))}</b></div>`).join('');
513
516
  document.getElementById('showAllShap').textContent = shapAll ? `Show top ${(D.shap && D.shap.topN) || features.length}` : `Show all ${features.length}`;
514
517
  }
@@ -534,7 +537,7 @@ const REPORT_DATA = __REPORT_DATA__;
534
537
  const body = document.getElementById('featureBody');
535
538
  body.innerHTML = rows.map(f =>
536
539
  `<tr data-name="${f.name}"><td><span class="feature-name" title="${f.name}">${f.name}</span></td>` +
537
- `<td><span class="provider">${f.provider}</span></td><td class="source-name">${f.source}</td>` +
540
+ `<td>${providerBadge(f.provider)}</td><td class="source-name">${f.source}</td>` +
538
541
  `<td>${shapLabel(f.shap)}</td><td><span class="tag ${f.status}">${fmt2(f.psi)}</span></td>` +
539
542
  `<td>${f.drift == null ? '—' : fmt2(f.drift)}</td><td>${pct1(f.coverage)}</td></tr>`).join('');
540
543
  document.getElementById('emptyState').style.display = rows.length ? 'none' : 'block';
@@ -210,6 +210,31 @@ class SearchTask:
210
210
  if meta.generated_features is not None:
211
211
  return meta.generated_features
212
212
 
213
+ def get_all_generated_features(self) -> List[GeneratedFeatureMetadata]:
214
+ if self.provider_metadata_v2 is None:
215
+ return []
216
+ generated: List[GeneratedFeatureMetadata] = []
217
+ for meta in self.provider_metadata_v2:
218
+ if meta.generated_features:
219
+ generated.extend(meta.generated_features)
220
+ return generated
221
+
222
+ def get_joined_ads_features_count(self) -> Optional[int]:
223
+ if self.provider_metadata_v2 is None:
224
+ return None
225
+ for meta in self.provider_metadata_v2:
226
+ if meta.joined_ads_features_count is not None:
227
+ return meta.joined_ads_features_count
228
+ return None
229
+
230
+ def get_joined_ads_count(self) -> Optional[int]:
231
+ if self.provider_metadata_v2 is None:
232
+ return None
233
+ for meta in self.provider_metadata_v2:
234
+ if meta.joined_ads_count is not None:
235
+ return meta.joined_ads_count
236
+ return None
237
+
213
238
  @staticmethod
214
239
  def _get_provider_summaries(summary: SearchTaskSummary) -> List[ProviderTaskSummary]:
215
240
  if summary.status in {
@@ -1 +0,0 @@
1
- __version__ = "1.2.167.a4220.dev7"