upgini 1.2.167a4220.dev7__tar.gz → 1.2.167a4220.dev8__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.dev8}/PKG-INFO +1 -1
  2. upgini-1.2.167a4220.dev8/src/upgini/__about__.py +1 -0
  3. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev8}/src/upgini/features_enricher.py +56 -47
  4. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev8}/src/upgini/report/assemble.py +49 -32
  5. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev8}/src/upgini/report/html.py +14 -0
  6. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev8}/src/upgini/report/template.html +4 -4
  7. upgini-1.2.167a4220.dev7/src/upgini/__about__.py +0 -1
  8. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev8}/.gitignore +0 -0
  9. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev8}/LICENSE +0 -0
  10. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev8}/README.md +0 -0
  11. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev8}/pyproject.toml +0 -0
  12. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev8}/src/upgini/__init__.py +0 -0
  13. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev8}/src/upgini/ads.py +0 -0
  14. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev8}/src/upgini/ads_management/__init__.py +0 -0
  15. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev8}/src/upgini/ads_management/ads_manager.py +0 -0
  16. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev8}/src/upgini/autofe/__init__.py +0 -0
  17. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev8}/src/upgini/autofe/all_operators.py +0 -0
  18. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev8}/src/upgini/autofe/binary.py +0 -0
  19. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev8}/src/upgini/autofe/date.py +0 -0
  20. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev8}/src/upgini/autofe/feature.py +0 -0
  21. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev8}/src/upgini/autofe/groupby.py +0 -0
  22. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev8}/src/upgini/autofe/operand.py +0 -0
  23. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev8}/src/upgini/autofe/operator.py +0 -0
  24. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev8}/src/upgini/autofe/timeseries/__init__.py +0 -0
  25. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev8}/src/upgini/autofe/timeseries/base.py +0 -0
  26. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev8}/src/upgini/autofe/timeseries/cross.py +0 -0
  27. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev8}/src/upgini/autofe/timeseries/delta.py +0 -0
  28. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev8}/src/upgini/autofe/timeseries/lag.py +0 -0
  29. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev8}/src/upgini/autofe/timeseries/numpy_kernels.py +0 -0
  30. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev8}/src/upgini/autofe/timeseries/roll.py +0 -0
  31. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev8}/src/upgini/autofe/timeseries/trend.py +0 -0
  32. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev8}/src/upgini/autofe/timeseries/volatility.py +0 -0
  33. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev8}/src/upgini/autofe/unary.py +0 -0
  34. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev8}/src/upgini/autofe/utils.py +0 -0
  35. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev8}/src/upgini/autofe/vector.py +0 -0
  36. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev8}/src/upgini/data_source/__init__.py +0 -0
  37. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev8}/src/upgini/data_source/data_source_publisher.py +0 -0
  38. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev8}/src/upgini/dataset.py +0 -0
  39. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev8}/src/upgini/errors.py +0 -0
  40. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev8}/src/upgini/http.py +0 -0
  41. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev8}/src/upgini/mdc/__init__.py +0 -0
  42. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev8}/src/upgini/mdc/context.py +0 -0
  43. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev8}/src/upgini/metadata.py +0 -0
  44. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev8}/src/upgini/metrics.py +0 -0
  45. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev8}/src/upgini/normalizer/__init__.py +0 -0
  46. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev8}/src/upgini/normalizer/normalize_utils.py +0 -0
  47. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev8}/src/upgini/report/__init__.py +0 -0
  48. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev8}/src/upgini/report/data.py +0 -0
  49. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev8}/src/upgini/report/stats.py +0 -0
  50. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev8}/src/upgini/resource_bundle/__init__.py +0 -0
  51. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev8}/src/upgini/resource_bundle/exceptions.py +0 -0
  52. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev8}/src/upgini/resource_bundle/strings.properties +0 -0
  53. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev8}/src/upgini/resource_bundle/strings_widget.properties +0 -0
  54. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev8}/src/upgini/sampler/__init__.py +0 -0
  55. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev8}/src/upgini/sampler/base.py +0 -0
  56. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev8}/src/upgini/sampler/random_under_sampler.py +0 -0
  57. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev8}/src/upgini/sampler/utils.py +0 -0
  58. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev8}/src/upgini/search_task.py +0 -0
  59. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev8}/src/upgini/spinner.py +0 -0
  60. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev8}/src/upgini/utils/Roboto-Regular.ttf +0 -0
  61. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev8}/src/upgini/utils/__init__.py +0 -0
  62. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev8}/src/upgini/utils/base_search_key_detector.py +0 -0
  63. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev8}/src/upgini/utils/blocked_time_series.py +0 -0
  64. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev8}/src/upgini/utils/config.py +0 -0
  65. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev8}/src/upgini/utils/country_utils.py +0 -0
  66. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev8}/src/upgini/utils/cpu_utils.py +0 -0
  67. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev8}/src/upgini/utils/custom_loss_utils.py +0 -0
  68. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev8}/src/upgini/utils/cv_utils.py +0 -0
  69. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev8}/src/upgini/utils/datetime_utils.py +0 -0
  70. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev8}/src/upgini/utils/deduplicate_utils.py +0 -0
  71. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev8}/src/upgini/utils/display_utils.py +0 -0
  72. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev8}/src/upgini/utils/email_utils.py +0 -0
  73. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev8}/src/upgini/utils/fallback_progress_bar.py +0 -0
  74. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev8}/src/upgini/utils/feature_info.py +0 -0
  75. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev8}/src/upgini/utils/features_validator.py +0 -0
  76. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev8}/src/upgini/utils/format.py +0 -0
  77. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev8}/src/upgini/utils/hash_utils.py +0 -0
  78. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev8}/src/upgini/utils/ip_utils.py +0 -0
  79. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev8}/src/upgini/utils/mstats.py +0 -0
  80. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev8}/src/upgini/utils/one_hot_encoder.py +0 -0
  81. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev8}/src/upgini/utils/phone_utils.py +0 -0
  82. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev8}/src/upgini/utils/postal_code_utils.py +0 -0
  83. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev8}/src/upgini/utils/progress_bar.py +0 -0
  84. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev8}/src/upgini/utils/psi.py +0 -0
  85. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev8}/src/upgini/utils/pyarrow_utils.py +0 -0
  86. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev8}/src/upgini/utils/sample_utils.py +0 -0
  87. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev8}/src/upgini/utils/search_key_derivations.py +0 -0
  88. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev8}/src/upgini/utils/sklearn_ext.py +0 -0
  89. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev8}/src/upgini/utils/sort.py +0 -0
  90. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev8}/src/upgini/utils/target_utils.py +0 -0
  91. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev8}/src/upgini/utils/track_info.py +0 -0
  92. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev8}/src/upgini/utils/ts_utils.py +0 -0
  93. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev8}/src/upgini/utils/warning_counter.py +0 -0
  94. {upgini-1.2.167a4220.dev7 → upgini-1.2.167a4220.dev8}/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.dev8
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.dev8"
@@ -98,7 +98,7 @@ from upgini.utils.deduplicate_utils import (
98
98
  clean_full_duplicates,
99
99
  remove_fintech_duplicates,
100
100
  )
101
- from upgini.report.assemble import assemble_report_data, autofe_rows_from_description, build_search_results
101
+ from upgini.report.assemble import assemble_report_data, autofe_rows_for_report, build_search_results
102
102
  from upgini.report.html import generate_html_report
103
103
  from upgini.report.stats import make_report_frame
104
104
  from upgini.utils.display_utils import (
@@ -2288,29 +2288,31 @@ class FeaturesEnricher(TransformerMixin):
2288
2288
  return self._get_single_ensemble_score_name() is not None
2289
2289
 
2290
2290
  def _get_single_ensemble_score_name(self) -> str | None:
2291
+ if len(self._ensemble_generated_metadata()) != 1:
2292
+ return None
2291
2293
  selected = list(self.feature_names_ or [])
2292
2294
  ensemble = [name for name in selected if self._is_ensemble_feature(name)]
2293
2295
  if len(ensemble) != 1:
2294
2296
  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):
2297
+ if any(self._is_extra_enriched_feature(name) for name in selected):
2298
2298
  return None
2299
2299
  return ensemble[0]
2300
2300
 
2301
- def _etalon_feature_aliases(self) -> set[str]:
2301
+ def _is_extra_enriched_feature(self, name: str) -> bool:
2302
+ if self._is_ensemble_feature(name):
2303
+ return False
2302
2304
  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)
2305
+ aliases = self._column_name_aliases([name], renaming)
2306
+ if aliases & set(self.external_source_feature_names or []):
2307
+ return True
2308
+ if self._search_task is None:
2309
+ return False
2310
+ for meta in self._search_task.get_all_features_metadata_v2() or []:
2311
+ if meta.source == "etalon" or self._is_ensemble_feature(meta.name):
2312
+ continue
2313
+ if aliases & self._column_name_aliases([meta.name], renaming):
2314
+ return True
2315
+ return False
2314
2316
 
2315
2317
  def _reference_rows(self) -> int | None:
2316
2318
  if self.X is None:
@@ -2472,12 +2474,18 @@ class FeaturesEnricher(TransformerMixin):
2472
2474
  if self._search_task is not None:
2473
2475
  features_meta = list(self._search_task.get_all_features_metadata_v2() or [])
2474
2476
  features, sources, model_shap, summary = build_search_results(
2475
- model_names=self._ensemble_model_feature_names(),
2476
2477
  features_meta=features_meta,
2477
- base_columns=self._ensemble_base_columns(),
2478
2478
  is_ensemble=self._is_ensemble_feature,
2479
+ generated_names=self._column_name_aliases(
2480
+ self.fit_generated_features or [], self.fit_columns_renaming or {}
2481
+ ),
2482
+ )
2483
+ autofe = autofe_rows_for_report(
2484
+ self.get_autofe_features_description(features_meta=features_meta),
2485
+ features_meta,
2486
+ self._is_ensemble_feature,
2487
+ self.bundle,
2479
2488
  )
2480
- autofe = autofe_rows_from_description(self.get_autofe_features_description(), self.bundle)
2481
2489
  return features, sources, model_shap, summary, autofe
2482
2490
 
2483
2491
  def _score_report_html(self) -> str | None:
@@ -2520,25 +2528,6 @@ class FeaturesEnricher(TransformerMixin):
2520
2528
  names.add(meta.alias)
2521
2529
  return names
2522
2530
 
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
2531
  def _psi_exceeds_threshold(self, psi: float | None, threshold: float) -> bool:
2543
2532
  return psi is not None and not pd.isna(psi) and psi > threshold
2544
2533
 
@@ -5950,12 +5939,12 @@ if response.status_code == 200:
5950
5939
  else:
5951
5940
  self.logger.warning("Empty features info")
5952
5941
 
5953
- def get_autofe_features_description(self):
5942
+ def get_autofe_features_description(self, features_meta: list[FeaturesMetadataV2] | None = None):
5954
5943
  try:
5955
5944
  autofe_meta = self._search_task.get_autofe_metadata()
5956
5945
  if autofe_meta is None:
5957
5946
  return None
5958
- if len(self._internal_features_info) != 0:
5947
+ if features_meta is None and len(self._internal_features_info) != 0:
5959
5948
 
5960
5949
  def to_feature_meta(row):
5961
5950
  fm = FeaturesMetadataV2(
@@ -5969,13 +5958,16 @@ if response.status_code == 200:
5969
5958
  return fm
5970
5959
 
5971
5960
  features_meta = self._internal_features_info.apply(to_feature_meta, axis=1).to_list()
5972
- else:
5961
+ elif features_meta is None:
5973
5962
  features_meta = self._search_task.get_all_features_metadata_v2()
5974
5963
 
5975
5964
  def get_feature_by_name(name: str):
5976
- for m in features_meta:
5977
- if m.name == name:
5978
- return m
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
5979
5971
 
5980
5972
  descriptions = []
5981
5973
  for m in autofe_meta:
@@ -5996,11 +5988,28 @@ if response.status_code == 200:
5996
5988
 
5997
5989
  description = {}
5998
5990
 
5999
- feature_meta = get_feature_by_name(autofe_feature.get_display_name(shorten=True, unhash=True))
6000
- if feature_meta is None:
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:
6001
6009
  continue
6002
6010
  description["shap"] = feature_meta.shap_value
6003
- description[self.bundle.get("autofe_descriptions_sources")] = feature_meta.data_source.replace(
6011
+ source = feature_meta.data_source or ""
6012
+ description[self.bundle.get("autofe_descriptions_sources")] = source.replace(
6004
6013
  "AutoFE: features from ", ""
6005
6014
  ).replace("AutoFE: feature from ", "")
6006
6015
  description[self.bundle.get("autofe_descriptions_feature_name")] = feature_meta.name
@@ -98,13 +98,20 @@ def assemble_report_data(
98
98
 
99
99
  def build_search_results(
100
100
  *,
101
- model_names: list[str],
102
101
  features_meta: list[FeaturesMetadataV2],
103
- base_columns: list[tuple[str, str]],
104
102
  is_ensemble: Callable[[str], bool],
103
+ generated_names: Optional[set[str]] = None,
105
104
  ) -> 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]
105
+ generated_names = generated_names or set()
106
+ model_rows: list[FeatureRow] = []
107
+ found_rows: list[FeatureRow] = []
108
+ for meta in features_meta:
109
+ if is_ensemble(meta.name) or not _has_nonzero_shap_value(meta.shap_value):
110
+ continue
111
+ row = _feature_row(meta.name, meta, generated_names)
112
+ model_rows.append(row)
113
+ if meta.source != "etalon":
114
+ found_rows.append(row)
108
115
  model_rows.sort(key=lambda row: (row.shap is None, -abs(row.shap or 0.0), row.name))
109
116
  model_shap = [
110
117
  ModelFeatureShap(
@@ -115,19 +122,12 @@ def build_search_results(
115
122
  )
116
123
  for index, row in enumerate(model_rows, start=1)
117
124
  ]
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
125
+ sources = _source_rows(found_rows)
126
+ contributed = len({(row.provider, row.source) for row in found_rows if row.provider})
127
127
  stable = sum(1 for row in model_rows if row.psi is not None and row.psi < _STABILITY_PSI)
128
128
  summary = SearchResultsSummary(
129
- relevant_features=relevant,
130
- model_features=model_count,
129
+ relevant_features=len(found_rows),
130
+ model_features=len(model_rows) or None,
131
131
  data_sources=len(sources),
132
132
  stable_features_share=(stable / len(model_rows)) if model_rows else None,
133
133
  contributed_sources=contributed,
@@ -136,6 +136,31 @@ def build_search_results(
136
136
  return model_rows, sources, model_shap, summary
137
137
 
138
138
 
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
+
139
164
  def autofe_rows_from_description(df: Optional[pd.DataFrame], bundle: ResourceBundle) -> list[dict[str, str]]:
140
165
  if df is None or df.empty:
141
166
  return []
@@ -234,23 +259,15 @@ def _as_int(value) -> Optional[int]:
234
259
  return None if pd.isna(value) else int(value)
235
260
 
236
261
 
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:
262
+ def _feature_row(
263
+ name: str, meta: Optional[FeaturesMetadataV2], generated_names: Optional[set[str]] = None
264
+ ) -> FeatureRow:
251
265
  if meta is None:
252
266
  return FeatureRow(name=name)
253
- info = FeatureInfo.from_metadata(meta, None, meta.source == "etalon", False)
267
+ generated_names = generated_names or set()
268
+ is_generated = name in generated_names or meta.source == "generated"
269
+ is_client = meta.source == "etalon" and not is_generated
270
+ info = FeatureInfo.from_metadata(meta, None, is_client, is_generated)
254
271
  return FeatureRow(
255
272
  name=name,
256
273
  shap=meta.shap_value,
@@ -292,5 +309,5 @@ def _psi_status(psi: Optional[float]) -> Optional[str]:
292
309
  return "good"
293
310
 
294
311
 
295
- def _has_nonzero_shap(row: FeatureRow) -> bool:
296
- return row.shap is not None and row.shap != 0
312
+ def _has_nonzero_shap_value(shap: Optional[float]) -> bool:
313
+ return shap is not None and not pd.isna(shap) and shap != 0
@@ -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")
@@ -160,6 +161,7 @@ def _features_payload(features: list[FeatureRow]) -> list[dict]:
160
161
  "name": row.name,
161
162
  "provider": row.provider,
162
163
  "source": row.source,
164
+ "shapClass": _shap_fill_class(row),
163
165
  "importance": abs(row.shap) if row.shap is not None else 0,
164
166
  "shap": row.shap,
165
167
  "coverage": row.coverage,
@@ -171,6 +173,18 @@ def _features_payload(features: list[FeatureRow]) -> list[dict]:
171
173
  ]
172
174
 
173
175
 
176
+ def _shap_fill_class(row: FeatureRow) -> str:
177
+ source = (row.source or "").lower()
178
+ provider = (row.provider or "").lower()
179
+ if source.startswith("autofe") or provider == "autofe" or source == GENERATED_SOURCE.lower():
180
+ return "autofe"
181
+ if source == CLIENT_SOURCE.lower() or provider == "user":
182
+ return "user"
183
+ if provider in {"", "upgini"}:
184
+ return "upgini"
185
+ return "external"
186
+
187
+
174
188
  def _sources_payload(sources: list[SourceRow]) -> list[dict]:
175
189
  return [
176
190
  {
@@ -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{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}
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}}
@@ -507,8 +507,8 @@ const REPORT_DATA = __REPORT_DATA__;
507
507
  const maxImportance = Math.max(...features.map(f => f.importance)) || 1;
508
508
  const list = shapAll ? features : features.slice(0, (D.shap && D.shap.topN) || features.length);
509
509
  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>` +
510
+ `<div class="shap-row"><div><b>#${i + 1} ${f.name}</b>${f.provider ? `<span class="provider">${f.provider}</span>` : ''}</div>` +
511
+ `<div class="shap-track"><i class="shap-fill ${f.shapClass || 'upgini'}" style="width:${(f.importance / maxImportance * 100).toFixed(2)}%"></i></div>` +
512
512
  `<b>${fmt3(Math.abs(f.shap))}</b></div>`).join('');
513
513
  document.getElementById('showAllShap').textContent = shapAll ? `Show top ${(D.shap && D.shap.topN) || features.length}` : `Show all ${features.length}`;
514
514
  }
@@ -534,7 +534,7 @@ const REPORT_DATA = __REPORT_DATA__;
534
534
  const body = document.getElementById('featureBody');
535
535
  body.innerHTML = rows.map(f =>
536
536
  `<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>` +
537
+ `<td>${f.provider ? `<span class="provider">${f.provider}</span>` : ''}</td><td class="source-name">${f.source}</td>` +
538
538
  `<td>${shapLabel(f.shap)}</td><td><span class="tag ${f.status}">${fmt2(f.psi)}</span></td>` +
539
539
  `<td>${f.drift == null ? '—' : fmt2(f.drift)}</td><td>${pct1(f.coverage)}</td></tr>`).join('');
540
540
  document.getElementById('emptyState').style.display = rows.length ? 'none' : 'block';
@@ -1 +0,0 @@
1
- __version__ = "1.2.167.a4220.dev7"