upgini 1.2.167a4220.dev8__tar.gz → 1.2.167a4220.dev10__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.dev8 → upgini-1.2.167a4220.dev10}/PKG-INFO +1 -1
  2. upgini-1.2.167a4220.dev10/src/upgini/__about__.py +1 -0
  3. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev10}/src/upgini/features_enricher.py +120 -69
  4. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev10}/src/upgini/metadata.py +2 -0
  5. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev10}/src/upgini/report/assemble.py +231 -36
  6. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev10}/src/upgini/report/data.py +6 -0
  7. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev10}/src/upgini/report/html.py +19 -5
  8. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev10}/src/upgini/report/template.html +8 -4
  9. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev10}/src/upgini/search_task.py +25 -0
  10. upgini-1.2.167a4220.dev8/src/upgini/__about__.py +0 -1
  11. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev10}/.gitignore +0 -0
  12. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev10}/LICENSE +0 -0
  13. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev10}/README.md +0 -0
  14. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev10}/pyproject.toml +0 -0
  15. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev10}/src/upgini/__init__.py +0 -0
  16. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev10}/src/upgini/ads.py +0 -0
  17. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev10}/src/upgini/ads_management/__init__.py +0 -0
  18. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev10}/src/upgini/ads_management/ads_manager.py +0 -0
  19. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev10}/src/upgini/autofe/__init__.py +0 -0
  20. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev10}/src/upgini/autofe/all_operators.py +0 -0
  21. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev10}/src/upgini/autofe/binary.py +0 -0
  22. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev10}/src/upgini/autofe/date.py +0 -0
  23. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev10}/src/upgini/autofe/feature.py +0 -0
  24. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev10}/src/upgini/autofe/groupby.py +0 -0
  25. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev10}/src/upgini/autofe/operand.py +0 -0
  26. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev10}/src/upgini/autofe/operator.py +0 -0
  27. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev10}/src/upgini/autofe/timeseries/__init__.py +0 -0
  28. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev10}/src/upgini/autofe/timeseries/base.py +0 -0
  29. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev10}/src/upgini/autofe/timeseries/cross.py +0 -0
  30. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev10}/src/upgini/autofe/timeseries/delta.py +0 -0
  31. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev10}/src/upgini/autofe/timeseries/lag.py +0 -0
  32. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev10}/src/upgini/autofe/timeseries/numpy_kernels.py +0 -0
  33. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev10}/src/upgini/autofe/timeseries/roll.py +0 -0
  34. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev10}/src/upgini/autofe/timeseries/trend.py +0 -0
  35. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev10}/src/upgini/autofe/timeseries/volatility.py +0 -0
  36. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev10}/src/upgini/autofe/unary.py +0 -0
  37. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev10}/src/upgini/autofe/utils.py +0 -0
  38. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev10}/src/upgini/autofe/vector.py +0 -0
  39. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev10}/src/upgini/data_source/__init__.py +0 -0
  40. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev10}/src/upgini/data_source/data_source_publisher.py +0 -0
  41. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev10}/src/upgini/dataset.py +0 -0
  42. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev10}/src/upgini/errors.py +0 -0
  43. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev10}/src/upgini/http.py +0 -0
  44. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev10}/src/upgini/mdc/__init__.py +0 -0
  45. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev10}/src/upgini/mdc/context.py +0 -0
  46. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev10}/src/upgini/metrics.py +0 -0
  47. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev10}/src/upgini/normalizer/__init__.py +0 -0
  48. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev10}/src/upgini/normalizer/normalize_utils.py +0 -0
  49. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev10}/src/upgini/report/__init__.py +0 -0
  50. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev10}/src/upgini/report/stats.py +0 -0
  51. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev10}/src/upgini/resource_bundle/__init__.py +0 -0
  52. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev10}/src/upgini/resource_bundle/exceptions.py +0 -0
  53. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev10}/src/upgini/resource_bundle/strings.properties +0 -0
  54. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev10}/src/upgini/resource_bundle/strings_widget.properties +0 -0
  55. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev10}/src/upgini/sampler/__init__.py +0 -0
  56. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev10}/src/upgini/sampler/base.py +0 -0
  57. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev10}/src/upgini/sampler/random_under_sampler.py +0 -0
  58. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev10}/src/upgini/sampler/utils.py +0 -0
  59. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev10}/src/upgini/spinner.py +0 -0
  60. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev10}/src/upgini/utils/Roboto-Regular.ttf +0 -0
  61. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev10}/src/upgini/utils/__init__.py +0 -0
  62. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev10}/src/upgini/utils/base_search_key_detector.py +0 -0
  63. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev10}/src/upgini/utils/blocked_time_series.py +0 -0
  64. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev10}/src/upgini/utils/config.py +0 -0
  65. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev10}/src/upgini/utils/country_utils.py +0 -0
  66. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev10}/src/upgini/utils/cpu_utils.py +0 -0
  67. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev10}/src/upgini/utils/custom_loss_utils.py +0 -0
  68. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev10}/src/upgini/utils/cv_utils.py +0 -0
  69. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev10}/src/upgini/utils/datetime_utils.py +0 -0
  70. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev10}/src/upgini/utils/deduplicate_utils.py +0 -0
  71. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev10}/src/upgini/utils/display_utils.py +0 -0
  72. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev10}/src/upgini/utils/email_utils.py +0 -0
  73. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev10}/src/upgini/utils/fallback_progress_bar.py +0 -0
  74. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev10}/src/upgini/utils/feature_info.py +0 -0
  75. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev10}/src/upgini/utils/features_validator.py +0 -0
  76. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev10}/src/upgini/utils/format.py +0 -0
  77. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev10}/src/upgini/utils/hash_utils.py +0 -0
  78. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev10}/src/upgini/utils/ip_utils.py +0 -0
  79. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev10}/src/upgini/utils/mstats.py +0 -0
  80. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev10}/src/upgini/utils/one_hot_encoder.py +0 -0
  81. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev10}/src/upgini/utils/phone_utils.py +0 -0
  82. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev10}/src/upgini/utils/postal_code_utils.py +0 -0
  83. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev10}/src/upgini/utils/progress_bar.py +0 -0
  84. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev10}/src/upgini/utils/psi.py +0 -0
  85. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev10}/src/upgini/utils/pyarrow_utils.py +0 -0
  86. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev10}/src/upgini/utils/sample_utils.py +0 -0
  87. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev10}/src/upgini/utils/search_key_derivations.py +0 -0
  88. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev10}/src/upgini/utils/sklearn_ext.py +0 -0
  89. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev10}/src/upgini/utils/sort.py +0 -0
  90. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev10}/src/upgini/utils/target_utils.py +0 -0
  91. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev10}/src/upgini/utils/track_info.py +0 -0
  92. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev10}/src/upgini/utils/ts_utils.py +0 -0
  93. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev10}/src/upgini/utils/warning_counter.py +0 -0
  94. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev10}/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.dev8
3
+ Version: 1.2.167a4220.dev10
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.dev10"
@@ -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,14 @@ 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_for_report, build_search_results
102
+ from upgini.report.assemble import (
103
+ _fold_model_nodes,
104
+ _generated_feature_names,
105
+ _parse_generated_feature,
106
+ assemble_report_data,
107
+ autofe_rows_from_description,
108
+ build_search_results,
109
+ )
102
110
  from upgini.report.html import generate_html_report
103
111
  from upgini.report.stats import make_report_frame
104
112
  from upgini.utils.display_utils import (
@@ -2479,11 +2487,14 @@ class FeaturesEnricher(TransformerMixin):
2479
2487
  generated_names=self._column_name_aliases(
2480
2488
  self.fit_generated_features or [], self.fit_columns_renaming or {}
2481
2489
  ),
2490
+ generated_features=self._search_task.get_all_generated_features() if self._search_task else [],
2491
+ joined_ads_features_count=(
2492
+ self._search_task.get_joined_ads_features_count() if self._search_task else None
2493
+ ),
2494
+ joined_ads_count=self._search_task.get_joined_ads_count() if self._search_task else None,
2482
2495
  )
2483
- autofe = autofe_rows_for_report(
2496
+ autofe = autofe_rows_from_description(
2484
2497
  self.get_autofe_features_description(features_meta=features_meta),
2485
- features_meta,
2486
- self._is_ensemble_feature,
2487
2498
  self.bundle,
2488
2499
  )
2489
2500
  return features, sources, model_shap, summary, autofe
@@ -2503,15 +2514,57 @@ class FeaturesEnricher(TransformerMixin):
2503
2514
  return False
2504
2515
  return bool(aliases & self._column_name_aliases(list(ensemble_names), renaming))
2505
2516
 
2517
+ @staticmethod
2518
+ def _match_autofe_meta_by_names(
2519
+ names: set[str],
2520
+ features_meta: list[FeaturesMetadataV2],
2521
+ used_names: set[str],
2522
+ ) -> FeaturesMetadataV2 | None:
2523
+ available = [meta for meta in features_meta or [] if meta.name not in used_names]
2524
+ by_name = {meta.name: meta for meta in available}
2525
+ for name in names:
2526
+ matched = by_name.get(name)
2527
+ if matched is not None:
2528
+ return matched
2529
+ return None
2530
+
2531
+ def _add_autofe_description(
2532
+ self,
2533
+ descriptions: list[dict],
2534
+ used_names: set[str],
2535
+ feature_meta: FeaturesMetadataV2 | None,
2536
+ source_names: list[str],
2537
+ operand_names: Iterable[str],
2538
+ ) -> None:
2539
+ if feature_meta is None or not feature_meta.shap_value:
2540
+ return
2541
+ description: dict = {}
2542
+ description["shap"] = feature_meta.shap_value
2543
+ source = feature_meta.data_source or ""
2544
+ description[self.bundle.get("autofe_descriptions_sources")] = source.replace(
2545
+ "AutoFE: features from ", ""
2546
+ ).replace("AutoFE: feature from ", "")
2547
+ description[self.bundle.get("autofe_descriptions_feature_name")] = feature_meta.name
2548
+ feature_idx = 1
2549
+ for hashed in source_names:
2550
+ description[self.bundle.get("autofe_descriptions_feature").format(feature_idx)] = hashed
2551
+ feature_idx += 1
2552
+ # Don't show features with more than 2 base columns
2553
+ if feature_idx > 3:
2554
+ return
2555
+ description[self.bundle.get("autofe_descriptions_function")] = ",".join(sorted(operand_names))
2556
+ used_names.add(feature_meta.name)
2557
+ descriptions.append(description)
2558
+
2506
2559
  def _ensemble_generated_metadata(self):
2507
2560
  autofe_meta = self._search_task.get_autofe_metadata() if self._search_task else None
2508
2561
  if not autofe_meta:
2509
2562
  return []
2510
2563
  matched = []
2511
2564
  for meta in autofe_meta:
2512
- op = EnsembleModel.from_formula(meta.formula.split("(")[0])
2513
- if op is not None:
2514
- matched.append((meta, op))
2565
+ feature = _parse_generated_feature(meta)
2566
+ if feature is not None and isinstance(feature.op, EnsembleModel):
2567
+ matched.append((meta, feature.op))
2515
2568
  return matched
2516
2569
 
2517
2570
  def _ensemble_feature_names_from_metadata(self) -> set[str]:
@@ -5864,6 +5917,7 @@ if response.status_code == 200:
5864
5917
 
5865
5918
  selected_features_meta.sort(key=lambda m: (-m.shap_value, m.name))
5866
5919
  psi_keepers = self._psi_stability_keepers() if self.psi_values is not None else set()
5920
+ single_ensemble = len(self._ensemble_generated_metadata()) == 1
5867
5921
 
5868
5922
  for feature_meta in selected_features_meta:
5869
5923
  original_name = original_names_dict.get(feature_meta.name, feature_meta.name)
@@ -5873,6 +5927,10 @@ if response.status_code == 200:
5873
5927
  ) or original_name in generated_aliases
5874
5928
  is_client_feature = original_name in clients_features_df.columns and not is_generated_feature
5875
5929
 
5930
+ # Model-input ads/AutoFE rows are only for the HTML report; the selected output is the score.
5931
+ if single_ensemble and not is_client_feature and not self._is_ensemble_feature(feature_meta.name):
5932
+ continue
5933
+
5876
5934
  if not is_client_feature and not is_generated_feature:
5877
5935
  self.external_source_feature_names.append(original_name)
5878
5936
 
@@ -5887,8 +5945,6 @@ if response.status_code == 200:
5887
5945
  if original_name in self.unstable_features or feature_meta.name in self.unstable_features:
5888
5946
  continue
5889
5947
 
5890
- # TODO make a decision about selected features based on special flag from mlb
5891
-
5892
5948
  if original_shaps.get(feature_meta.name, 0.0) == 0.0 and not self._should_show_client_feature_in_report(
5893
5949
  is_client_feature, feature_meta.name
5894
5950
  ):
@@ -5961,75 +6017,68 @@ if response.status_code == 200:
5961
6017
  elif features_meta is None:
5962
6018
  features_meta = self._search_task.get_all_features_metadata_v2()
5963
6019
 
5964
- def get_feature_by_name(name: str):
5965
- if not name or not features_meta:
5966
- return None
5967
- for candidate in features_meta:
5968
- if candidate.name == name:
5969
- return candidate
5970
- return None
5971
-
5972
6020
  descriptions = []
6021
+ used_names: set[str] = set()
5973
6022
  for m in autofe_meta:
5974
- orig_to_hashed = {base_column.original_name: base_column.hashed_name for base_column in m.base_columns}
5975
-
5976
- autofe_feature = (
5977
- Feature.from_formula(m.formula)
5978
- .set_display_index(m.display_index)
5979
- .set_alias(m.alias)
5980
- .set_op_params(m.operator_params or {})
5981
- .rename_columns(orig_to_hashed)
5982
- )
5983
-
5984
- is_ts = isinstance(autofe_feature.op, TimeSeriesBase)
5985
-
5986
- if autofe_feature.op.is_vector and not is_ts:
6023
+ try:
6024
+ orig_to_hashed = {
6025
+ base_column.original_name: base_column.hashed_name for base_column in m.base_columns
6026
+ }
6027
+ parsed = Feature.from_formula(m.formula)
6028
+ if not isinstance(parsed, Feature):
6029
+ continue
6030
+ autofe_feature = (
6031
+ parsed.set_display_index(m.display_index or None)
6032
+ .set_alias(m.alias)
6033
+ .set_op_params(m.operator_params or {})
6034
+ .rename_columns(orig_to_hashed)
6035
+ )
6036
+ except Exception:
6037
+ self.logger.exception(f"Failed to parse AutoFE formula: {m.formula}")
5987
6038
  continue
5988
6039
 
5989
- description = {}
5990
-
5991
- lookup_names = [autofe_feature.get_display_name(shorten=True, unhash=True)]
5992
- if m.alias:
5993
- lookup_names.extend([m.alias, f"f_autofe_{m.alias}"])
5994
- feature_meta = None
5995
- for lookup_name in lookup_names:
5996
- feature_meta = get_feature_by_name(lookup_name)
5997
- if feature_meta is not None:
5998
- break
5999
- if feature_meta is None and m.alias and features_meta:
6000
- prefix = f"f_autofe_{m.alias}"
6001
- matches = [
6002
- candidate
6003
- for candidate in features_meta
6004
- if candidate.name == prefix or candidate.name.startswith(prefix + "_")
6005
- ]
6006
- if len(matches) == 1:
6007
- feature_meta = matches[0]
6008
- if feature_meta is None or not feature_meta.shap_value:
6040
+ fold_autofe = [
6041
+ child
6042
+ for fold in _fold_model_nodes(autofe_feature)
6043
+ for child in fold.children
6044
+ if isinstance(child, Feature)
6045
+ ]
6046
+ if fold_autofe:
6047
+ hashed_to_orig = {hashed: orig for orig, hashed in orig_to_hashed.items()}
6048
+ for node in fold_autofe:
6049
+ is_ts = isinstance(node.op, TimeSeriesBase)
6050
+ source_names = [
6051
+ hashed
6052
+ for hashed in node.get_columns()
6053
+ if not is_ts or hashed_to_orig.get(hashed, hashed) not in self.fit_search_keys
6054
+ ]
6055
+ name = node.get_display_name(shorten=True, unhash=True, cache=False)
6056
+ self._add_autofe_description(
6057
+ descriptions,
6058
+ used_names,
6059
+ self._match_autofe_meta_by_names({name} if name else set(), features_meta, used_names),
6060
+ source_names,
6061
+ node.get_all_operand_names(),
6062
+ )
6009
6063
  continue
6010
- description["shap"] = feature_meta.shap_value
6011
- source = feature_meta.data_source or ""
6012
- description[self.bundle.get("autofe_descriptions_sources")] = source.replace(
6013
- "AutoFE: features from ", ""
6014
- ).replace("AutoFE: feature from ", "")
6015
- description[self.bundle.get("autofe_descriptions_feature_name")] = feature_meta.name
6016
-
6017
- feature_idx = 1
6018
- for bc in m.base_columns:
6019
- if not is_ts or bc.original_name not in self.fit_search_keys:
6020
- description[self.bundle.get("autofe_descriptions_feature").format(feature_idx)] = bc.hashed_name
6021
- feature_idx += 1
6022
-
6023
- # Don't show features with more than 2 base columns
6024
- if feature_idx > 3:
6064
+
6065
+ is_ts = isinstance(autofe_feature.op, TimeSeriesBase)
6066
+ if autofe_feature.op.is_vector and not is_ts:
6025
6067
  continue
6026
6068
 
6027
- description[self.bundle.get("autofe_descriptions_function")] = ",".join(
6028
- sorted(autofe_feature.get_all_operand_names())
6069
+ source_names = [
6070
+ bc.hashed_name
6071
+ for bc in m.base_columns
6072
+ if not is_ts or bc.original_name not in self.fit_search_keys
6073
+ ]
6074
+ self._add_autofe_description(
6075
+ descriptions,
6076
+ used_names,
6077
+ self._match_autofe_meta_by_names(_generated_feature_names(m), features_meta, used_names),
6078
+ source_names,
6079
+ autofe_feature.get_all_operand_names(),
6029
6080
  )
6030
6081
 
6031
- descriptions.append(description)
6032
-
6033
6082
  if len(descriptions) == 0:
6034
6083
  return None
6035
6084
 
@@ -6206,6 +6255,8 @@ if response.status_code == 200:
6206
6255
  display_html_dataframe(self.metrics, self.metrics, msg)
6207
6256
 
6208
6257
  def __show_selected_features(self):
6258
+ if self._has_single_ensemble_score():
6259
+ return
6209
6260
  try:
6210
6261
  _ = get_ipython() # type: ignore
6211
6262
 
@@ -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 Column, 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,
@@ -101,6 +103,9 @@ def build_search_results(
101
103
  features_meta: list[FeaturesMetadataV2],
102
104
  is_ensemble: Callable[[str], bool],
103
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,
104
109
  ) -> tuple[list[FeatureRow], list[SourceRow], list[ModelFeatureShap], SearchResultsSummary]:
105
110
  generated_names = generated_names or set()
106
111
  model_rows: list[FeatureRow] = []
@@ -123,44 +128,19 @@ def build_search_results(
123
128
  for index, row in enumerate(model_rows, start=1)
124
129
  ]
125
130
  sources = _source_rows(found_rows)
126
- contributed = len({(row.provider, row.source) for row in found_rows if row.provider})
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=len(found_rows),
130
- model_features=len(model_rows) or None,
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,
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
 
138
143
 
139
- def autofe_rows_for_report(
140
- df: Optional[pd.DataFrame],
141
- features_meta: list[FeaturesMetadataV2],
142
- is_ensemble: Callable[[str], bool],
143
- bundle: ResourceBundle,
144
- ) -> list[dict[str, str]]:
145
- rows = autofe_rows_from_description(df, bundle)
146
- listed = {row["generatedFeature"] for row in rows if row.get("generatedFeature")}
147
- for meta in features_meta:
148
- if is_ensemble(meta.name) or meta.source != "generated":
149
- continue
150
- if not _has_nonzero_shap_value(meta.shap_value) or meta.name in listed:
151
- continue
152
- row = _feature_row(meta.name, meta)
153
- rows.append(
154
- {
155
- "sources": row.source,
156
- "generatedFeature": row.name,
157
- "sourceFeatures": "",
158
- "functions": "",
159
- }
160
- )
161
- return rows
162
-
163
-
164
144
  def autofe_rows_from_description(df: Optional[pd.DataFrame], bundle: ResourceBundle) -> list[dict[str, str]]:
165
145
  if df is None or df.empty:
166
146
  return []
@@ -275,11 +255,20 @@ def _feature_row(
275
255
  drift=meta.drift_score,
276
256
  coverage=meta.hit_rate,
277
257
  provider=info.internal_provider,
278
- source=info.internal_source,
258
+ source=_feature_source(meta, is_generated, info.internal_source),
279
259
  stability_status=_psi_status(meta.psi_value),
280
260
  )
281
261
 
282
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
+
283
272
  def _source_rows(rows: list[FeatureRow]) -> list[SourceRow]:
284
273
  grouped: dict[tuple[str, str], list[FeatureRow]] = {}
285
274
  for row in rows:
@@ -311,3 +300,209 @@ def _psi_status(psi: Optional[float]) -> Optional[str]:
311
300
 
312
301
  def _has_nonzero_shap_value(shap: Optional[float]) -> bool:
313
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_inputs(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
+ columns_by_name = _base_columns_by_name(generated_features)
320
+ ads_ids = _contributed_ads_ids(generated_features, autofe_by_name)
321
+ if top_level:
322
+ counts = {"external": 0, "original": 0, "autofe": 0}
323
+ psis: list[Optional[float]] = []
324
+ for node in top_level:
325
+ kind = _top_level_kind(node, features_by_name, columns_by_name, autofe_by_name, is_ensemble)
326
+ counts[kind] += 1
327
+ meta = _feature_meta_for_node(node, features_by_name, columns_by_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 or set())
332
+ else:
333
+ model_features = len(model_rows) or None
334
+ external = original = autofe = None
335
+ contributed = (
336
+ len(ads_ids)
337
+ if ads_ids is not None
338
+ else len({(row.provider, row.source) for row in found_rows if row.provider})
339
+ )
340
+ psis = [row.psi for row in model_rows]
341
+ share, stable, psi_count = _stability_from_psi(psis)
342
+ return SearchResultsSummary(
343
+ relevant_features=len(found_rows),
344
+ features_found=joined_ads_features_count if joined_ads_features_count is not None else len(found_rows),
345
+ model_features=model_features,
346
+ external_features=external,
347
+ original_features=original,
348
+ autofe_features=autofe,
349
+ data_sources=len(sources),
350
+ joined_sources=joined_ads_count if joined_ads_count is not None else len(sources),
351
+ stable_features_share=share,
352
+ contributed_sources=contributed,
353
+ stable_features=stable,
354
+ psi_features=psi_count,
355
+ )
356
+
357
+
358
+ def _unique_fold_model_inputs(generated_features: list[GeneratedFeatureMetadata]) -> list[Column | Feature]:
359
+ unique: dict[str, Column | Feature] = {}
360
+ for meta in generated_features:
361
+ feature = _parse_generated_feature(meta)
362
+ if feature is None:
363
+ continue
364
+ for fold in _fold_model_nodes(feature):
365
+ for child in fold.children:
366
+ unique.setdefault(_top_level_key(child), child)
367
+ return list(unique.values())
368
+
369
+
370
+ def _fold_model_nodes(feature: Feature) -> list[Feature]:
371
+ if isinstance(feature.op, (CatboostModel, OnnxModel)):
372
+ return [feature]
373
+ nodes: list[Feature] = []
374
+ for child in feature.children:
375
+ if isinstance(child, Feature):
376
+ nodes.extend(_fold_model_nodes(child))
377
+ return nodes
378
+
379
+
380
+ def _top_level_key(node: Column | Feature) -> str:
381
+ if isinstance(node, Column):
382
+ return f"col:{node.name}"
383
+ return f"fe:{node.to_formula()}"
384
+
385
+
386
+ def _base_columns_by_name(generated_features: list[GeneratedFeatureMetadata]) -> dict[str, BaseColumnMetadata]:
387
+ by_name: dict[str, BaseColumnMetadata] = {}
388
+ for meta in generated_features:
389
+ for column in meta.base_columns:
390
+ if column.hashed_name:
391
+ by_name.setdefault(column.hashed_name, column)
392
+ if column.original_name:
393
+ by_name.setdefault(column.original_name, column)
394
+ return by_name
395
+
396
+
397
+ def _column_names(node: Column, columns_by_name: dict[str, BaseColumnMetadata]) -> list[str]:
398
+ names = [node.name]
399
+ column = columns_by_name.get(node.name)
400
+ if column is not None:
401
+ names.extend([column.hashed_name, column.original_name])
402
+ return [name for name in names if name]
403
+
404
+
405
+ def _feature_meta_for_node(
406
+ node: Column | Feature,
407
+ features_by_name: dict[str, FeaturesMetadataV2],
408
+ columns_by_name: dict[str, BaseColumnMetadata],
409
+ ) -> Optional[FeaturesMetadataV2]:
410
+ if not isinstance(node, Column):
411
+ return None
412
+ return next(
413
+ (features_by_name[name] for name in _column_names(node, columns_by_name) if name in features_by_name),
414
+ None,
415
+ )
416
+
417
+
418
+ def _autofe_generated_by_name(
419
+ generated_features: list[GeneratedFeatureMetadata], is_ensemble: Callable[[str], bool]
420
+ ) -> dict[str, GeneratedFeatureMetadata]:
421
+ by_name: dict[str, GeneratedFeatureMetadata] = {}
422
+ for meta in generated_features:
423
+ feature = _parse_generated_feature(meta)
424
+ if feature is None or isinstance(feature.op, (CatboostModel, EnsembleModel, OnnxModel)):
425
+ continue
426
+ name = feature.get_display_name(shorten=True, unhash=True, cache=False)
427
+ if name and not is_ensemble(name):
428
+ by_name.setdefault(name, meta)
429
+ return by_name
430
+
431
+
432
+ def _parse_generated_feature(meta: GeneratedFeatureMetadata) -> Feature | None:
433
+ try:
434
+ parsed = Feature.from_formula(meta.formula)
435
+ if not isinstance(parsed, Feature):
436
+ return None
437
+ orig_to_hashed = {column.original_name: column.hashed_name for column in meta.base_columns}
438
+ return (
439
+ parsed.set_display_index(meta.display_index or None)
440
+ .set_alias(meta.alias)
441
+ .set_op_params(meta.operator_params or {})
442
+ .rename_columns(orig_to_hashed)
443
+ )
444
+ except Exception:
445
+ return None
446
+
447
+
448
+ def _generated_feature_names(meta: GeneratedFeatureMetadata) -> set[str]:
449
+ feature = _parse_generated_feature(meta)
450
+ if feature is None:
451
+ return set()
452
+ return {feature.get_display_name(shorten=True, unhash=True, cache=False)}
453
+
454
+
455
+ def _top_level_kind(
456
+ node: Column | Feature,
457
+ features_by_name: dict[str, FeaturesMetadataV2],
458
+ columns_by_name: dict[str, BaseColumnMetadata],
459
+ autofe_by_name: dict[str, GeneratedFeatureMetadata],
460
+ is_ensemble: Callable[[str], bool],
461
+ ) -> str:
462
+ if isinstance(node, Feature):
463
+ return "autofe"
464
+ names = _column_names(node, columns_by_name)
465
+ if any(name in autofe_by_name for name in names):
466
+ return "autofe"
467
+ meta = next((features_by_name[name] for name in names if name in features_by_name), None)
468
+ if meta is not None and not is_ensemble(meta.name):
469
+ if meta.source == "generated":
470
+ return "autofe"
471
+ if meta.source == "ads":
472
+ return "external"
473
+ if meta.source == "etalon":
474
+ return "original"
475
+ column = next((columns_by_name[name] for name in names if name in columns_by_name), None)
476
+ if column is not None and column.ads_definition_id:
477
+ return "external"
478
+ return "original"
479
+
480
+
481
+ def _contributed_ads_ids(
482
+ generated_features: list[GeneratedFeatureMetadata],
483
+ autofe_by_name: dict[str, GeneratedFeatureMetadata],
484
+ ) -> Optional[set[str]]:
485
+ ads_ids: Optional[set[str]] = None
486
+ for meta in generated_features:
487
+ feature = _parse_generated_feature(meta)
488
+ if feature is None or not _fold_model_nodes(feature):
489
+ continue
490
+ if ads_ids is None:
491
+ ads_ids = set()
492
+ for column in meta.base_columns:
493
+ ads_ids.update(_leaf_ads_ids(column, autofe_by_name))
494
+ return ads_ids
495
+
496
+
497
+ def _leaf_ads_ids(column: BaseColumnMetadata, autofe_by_name: dict[str, GeneratedFeatureMetadata]) -> set[str]:
498
+ autofe = autofe_by_name.get(column.hashed_name) or autofe_by_name.get(column.original_name)
499
+ leaves = autofe.base_columns if autofe is not None else [column]
500
+ return {leaf.ads_definition_id for leaf in leaves if leaf.ads_definition_id}
501
+
502
+
503
+ def _stability_from_psi(psis: list[Optional[float]]) -> tuple[Optional[float], Optional[int], Optional[int]]:
504
+ values = [psi for psi in psis if psi is not None and not pd.isna(psi)]
505
+ if not values:
506
+ return None, None, None
507
+ stable = sum(1 for psi in values if psi < _STABILITY_PSI)
508
+ 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
@@ -145,16 +145,30 @@ def _summary_cards(summary: SearchResultsSummary) -> list[dict]:
145
145
  if summary.contributed_sources is not None:
146
146
  sources_caption = f"{summary.contributed_sources} contributed"
147
147
  stability_caption = ""
148
- if summary.stable_features is not None and summary.model_features is not None:
149
- 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
150
152
  return [
151
- {"label": "Features found", "value": _dash(summary.relevant_features), "caption": ""},
152
- {"label": "Used in model", "value": _dash(summary.model_features), "caption": ""},
153
- {"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},
154
156
  {"label": "Stability", "value": _dash(stability), "caption": stability_caption},
155
157
  ]
156
158
 
157
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
+
158
172
  def _features_payload(features: list[FeatureRow]) -> list[dict]:
159
173
  return [
160
174
  {
@@ -7,8 +7,9 @@
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}.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}
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:fit-content(55%) minmax(90px,1fr) 64px;gap:12px;align-items:center;padding:9px 4px;border-bottom:1px solid #edf1ef}.shap-row:last-child{border:0}.shap-name{min-width:0;overflow-wrap:anywhere;word-break:break-word}.shap-value{text-align:right;white-space:nowrap;font-variant-numeric:tabular-nums}.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
+ @supports (grid-template-columns:subgrid){.shap-panel{display:grid;grid-template-columns:fit-content(55%) minmax(90px,1fr) 64px;column-gap:12px}.shap-row{grid-template-columns:subgrid;grid-column:1/-1;padding-left:0;padding-right:0}}
12
13
  @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
14
  @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}}
14
15
  </style>
@@ -127,6 +128,9 @@ const REPORT_DATA = __REPORT_DATA__;
127
128
  const pct1 = v => (v == null ? '—' : Number(v).toFixed(1) + '%');
128
129
  const signed3 = v => (v >= 0 ? '+' : '') + Number(v).toFixed(3);
129
130
  const setText = (id, text) => { const el = document.getElementById(id); if (el) el.textContent = text; };
131
+ const providerBadge = provider => provider
132
+ ? `<span class="provider">${String(provider).replace(/,\s*/g, '<br>')}</span>`
133
+ : '';
130
134
  function rgba(hex, alpha) {
131
135
  const h = hex.replace('#', '');
132
136
  const n = parseInt(h.length === 3 ? h.split('').map(x => x + x).join('') : h, 16);
@@ -507,9 +511,9 @@ const REPORT_DATA = __REPORT_DATA__;
507
511
  const maxImportance = Math.max(...features.map(f => f.importance)) || 1;
508
512
  const list = shapAll ? features : features.slice(0, (D.shap && D.shap.topN) || features.length);
509
513
  document.getElementById('shapList').innerHTML = list.map((f, i) =>
510
- `<div class="shap-row"><div><b>#${i + 1} ${f.name}</b>${f.provider ? `<span class="provider">${f.provider}</span>` : ''}</div>` +
514
+ `<div class="shap-row"><div class="shap-name"><b>#${i + 1} ${f.name}</b>${providerBadge(f.provider)}</div>` +
511
515
  `<div class="shap-track"><i class="shap-fill ${f.shapClass || 'upgini'}" style="width:${(f.importance / maxImportance * 100).toFixed(2)}%"></i></div>` +
512
- `<b>${fmt3(Math.abs(f.shap))}</b></div>`).join('');
516
+ `<b class="shap-value">${fmt3(Math.abs(f.shap))}</b></div>`).join('');
513
517
  document.getElementById('showAllShap').textContent = shapAll ? `Show top ${(D.shap && D.shap.topN) || features.length}` : `Show all ${features.length}`;
514
518
  }
515
519
 
@@ -534,7 +538,7 @@ const REPORT_DATA = __REPORT_DATA__;
534
538
  const body = document.getElementById('featureBody');
535
539
  body.innerHTML = rows.map(f =>
536
540
  `<tr data-name="${f.name}"><td><span class="feature-name" title="${f.name}">${f.name}</span></td>` +
537
- `<td>${f.provider ? `<span class="provider">${f.provider}</span>` : ''}</td><td class="source-name">${f.source}</td>` +
541
+ `<td>${providerBadge(f.provider)}</td><td class="source-name">${f.source}</td>` +
538
542
  `<td>${shapLabel(f.shap)}</td><td><span class="tag ${f.status}">${fmt2(f.psi)}</span></td>` +
539
543
  `<td>${f.drift == null ? '—' : fmt2(f.drift)}</td><td>${pct1(f.coverage)}</td></tr>`).join('');
540
544
  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.dev8"