upgini 1.2.167a4220.dev9__tar.gz → 1.2.167a4220.dev11__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.dev9 → upgini-1.2.167a4220.dev11}/PKG-INFO +1 -1
  2. upgini-1.2.167a4220.dev11/src/upgini/__about__.py +1 -0
  3. {upgini-1.2.167a4220.dev9 → upgini-1.2.167a4220.dev11}/src/upgini/features_enricher.py +107 -33
  4. {upgini-1.2.167a4220.dev9 → upgini-1.2.167a4220.dev11}/src/upgini/report/assemble.py +91 -18
  5. {upgini-1.2.167a4220.dev9 → upgini-1.2.167a4220.dev11}/src/upgini/report/template.html +4 -3
  6. upgini-1.2.167a4220.dev9/src/upgini/__about__.py +0 -1
  7. {upgini-1.2.167a4220.dev9 → upgini-1.2.167a4220.dev11}/.gitignore +0 -0
  8. {upgini-1.2.167a4220.dev9 → upgini-1.2.167a4220.dev11}/LICENSE +0 -0
  9. {upgini-1.2.167a4220.dev9 → upgini-1.2.167a4220.dev11}/README.md +0 -0
  10. {upgini-1.2.167a4220.dev9 → upgini-1.2.167a4220.dev11}/pyproject.toml +0 -0
  11. {upgini-1.2.167a4220.dev9 → upgini-1.2.167a4220.dev11}/src/upgini/__init__.py +0 -0
  12. {upgini-1.2.167a4220.dev9 → upgini-1.2.167a4220.dev11}/src/upgini/ads.py +0 -0
  13. {upgini-1.2.167a4220.dev9 → upgini-1.2.167a4220.dev11}/src/upgini/ads_management/__init__.py +0 -0
  14. {upgini-1.2.167a4220.dev9 → upgini-1.2.167a4220.dev11}/src/upgini/ads_management/ads_manager.py +0 -0
  15. {upgini-1.2.167a4220.dev9 → upgini-1.2.167a4220.dev11}/src/upgini/autofe/__init__.py +0 -0
  16. {upgini-1.2.167a4220.dev9 → upgini-1.2.167a4220.dev11}/src/upgini/autofe/all_operators.py +0 -0
  17. {upgini-1.2.167a4220.dev9 → upgini-1.2.167a4220.dev11}/src/upgini/autofe/binary.py +0 -0
  18. {upgini-1.2.167a4220.dev9 → upgini-1.2.167a4220.dev11}/src/upgini/autofe/date.py +0 -0
  19. {upgini-1.2.167a4220.dev9 → upgini-1.2.167a4220.dev11}/src/upgini/autofe/feature.py +0 -0
  20. {upgini-1.2.167a4220.dev9 → upgini-1.2.167a4220.dev11}/src/upgini/autofe/groupby.py +0 -0
  21. {upgini-1.2.167a4220.dev9 → upgini-1.2.167a4220.dev11}/src/upgini/autofe/operand.py +0 -0
  22. {upgini-1.2.167a4220.dev9 → upgini-1.2.167a4220.dev11}/src/upgini/autofe/operator.py +0 -0
  23. {upgini-1.2.167a4220.dev9 → upgini-1.2.167a4220.dev11}/src/upgini/autofe/timeseries/__init__.py +0 -0
  24. {upgini-1.2.167a4220.dev9 → upgini-1.2.167a4220.dev11}/src/upgini/autofe/timeseries/base.py +0 -0
  25. {upgini-1.2.167a4220.dev9 → upgini-1.2.167a4220.dev11}/src/upgini/autofe/timeseries/cross.py +0 -0
  26. {upgini-1.2.167a4220.dev9 → upgini-1.2.167a4220.dev11}/src/upgini/autofe/timeseries/delta.py +0 -0
  27. {upgini-1.2.167a4220.dev9 → upgini-1.2.167a4220.dev11}/src/upgini/autofe/timeseries/lag.py +0 -0
  28. {upgini-1.2.167a4220.dev9 → upgini-1.2.167a4220.dev11}/src/upgini/autofe/timeseries/numpy_kernels.py +0 -0
  29. {upgini-1.2.167a4220.dev9 → upgini-1.2.167a4220.dev11}/src/upgini/autofe/timeseries/roll.py +0 -0
  30. {upgini-1.2.167a4220.dev9 → upgini-1.2.167a4220.dev11}/src/upgini/autofe/timeseries/trend.py +0 -0
  31. {upgini-1.2.167a4220.dev9 → upgini-1.2.167a4220.dev11}/src/upgini/autofe/timeseries/volatility.py +0 -0
  32. {upgini-1.2.167a4220.dev9 → upgini-1.2.167a4220.dev11}/src/upgini/autofe/unary.py +0 -0
  33. {upgini-1.2.167a4220.dev9 → upgini-1.2.167a4220.dev11}/src/upgini/autofe/utils.py +0 -0
  34. {upgini-1.2.167a4220.dev9 → upgini-1.2.167a4220.dev11}/src/upgini/autofe/vector.py +0 -0
  35. {upgini-1.2.167a4220.dev9 → upgini-1.2.167a4220.dev11}/src/upgini/data_source/__init__.py +0 -0
  36. {upgini-1.2.167a4220.dev9 → upgini-1.2.167a4220.dev11}/src/upgini/data_source/data_source_publisher.py +0 -0
  37. {upgini-1.2.167a4220.dev9 → upgini-1.2.167a4220.dev11}/src/upgini/dataset.py +0 -0
  38. {upgini-1.2.167a4220.dev9 → upgini-1.2.167a4220.dev11}/src/upgini/errors.py +0 -0
  39. {upgini-1.2.167a4220.dev9 → upgini-1.2.167a4220.dev11}/src/upgini/http.py +0 -0
  40. {upgini-1.2.167a4220.dev9 → upgini-1.2.167a4220.dev11}/src/upgini/mdc/__init__.py +0 -0
  41. {upgini-1.2.167a4220.dev9 → upgini-1.2.167a4220.dev11}/src/upgini/mdc/context.py +0 -0
  42. {upgini-1.2.167a4220.dev9 → upgini-1.2.167a4220.dev11}/src/upgini/metadata.py +0 -0
  43. {upgini-1.2.167a4220.dev9 → upgini-1.2.167a4220.dev11}/src/upgini/metrics.py +0 -0
  44. {upgini-1.2.167a4220.dev9 → upgini-1.2.167a4220.dev11}/src/upgini/normalizer/__init__.py +0 -0
  45. {upgini-1.2.167a4220.dev9 → upgini-1.2.167a4220.dev11}/src/upgini/normalizer/normalize_utils.py +0 -0
  46. {upgini-1.2.167a4220.dev9 → upgini-1.2.167a4220.dev11}/src/upgini/report/__init__.py +0 -0
  47. {upgini-1.2.167a4220.dev9 → upgini-1.2.167a4220.dev11}/src/upgini/report/data.py +0 -0
  48. {upgini-1.2.167a4220.dev9 → upgini-1.2.167a4220.dev11}/src/upgini/report/html.py +0 -0
  49. {upgini-1.2.167a4220.dev9 → upgini-1.2.167a4220.dev11}/src/upgini/report/stats.py +0 -0
  50. {upgini-1.2.167a4220.dev9 → upgini-1.2.167a4220.dev11}/src/upgini/resource_bundle/__init__.py +0 -0
  51. {upgini-1.2.167a4220.dev9 → upgini-1.2.167a4220.dev11}/src/upgini/resource_bundle/exceptions.py +0 -0
  52. {upgini-1.2.167a4220.dev9 → upgini-1.2.167a4220.dev11}/src/upgini/resource_bundle/strings.properties +0 -0
  53. {upgini-1.2.167a4220.dev9 → upgini-1.2.167a4220.dev11}/src/upgini/resource_bundle/strings_widget.properties +0 -0
  54. {upgini-1.2.167a4220.dev9 → upgini-1.2.167a4220.dev11}/src/upgini/sampler/__init__.py +0 -0
  55. {upgini-1.2.167a4220.dev9 → upgini-1.2.167a4220.dev11}/src/upgini/sampler/base.py +0 -0
  56. {upgini-1.2.167a4220.dev9 → upgini-1.2.167a4220.dev11}/src/upgini/sampler/random_under_sampler.py +0 -0
  57. {upgini-1.2.167a4220.dev9 → upgini-1.2.167a4220.dev11}/src/upgini/sampler/utils.py +0 -0
  58. {upgini-1.2.167a4220.dev9 → upgini-1.2.167a4220.dev11}/src/upgini/search_task.py +0 -0
  59. {upgini-1.2.167a4220.dev9 → upgini-1.2.167a4220.dev11}/src/upgini/spinner.py +0 -0
  60. {upgini-1.2.167a4220.dev9 → upgini-1.2.167a4220.dev11}/src/upgini/utils/Roboto-Regular.ttf +0 -0
  61. {upgini-1.2.167a4220.dev9 → upgini-1.2.167a4220.dev11}/src/upgini/utils/__init__.py +0 -0
  62. {upgini-1.2.167a4220.dev9 → upgini-1.2.167a4220.dev11}/src/upgini/utils/base_search_key_detector.py +0 -0
  63. {upgini-1.2.167a4220.dev9 → upgini-1.2.167a4220.dev11}/src/upgini/utils/blocked_time_series.py +0 -0
  64. {upgini-1.2.167a4220.dev9 → upgini-1.2.167a4220.dev11}/src/upgini/utils/config.py +0 -0
  65. {upgini-1.2.167a4220.dev9 → upgini-1.2.167a4220.dev11}/src/upgini/utils/country_utils.py +0 -0
  66. {upgini-1.2.167a4220.dev9 → upgini-1.2.167a4220.dev11}/src/upgini/utils/cpu_utils.py +0 -0
  67. {upgini-1.2.167a4220.dev9 → upgini-1.2.167a4220.dev11}/src/upgini/utils/custom_loss_utils.py +0 -0
  68. {upgini-1.2.167a4220.dev9 → upgini-1.2.167a4220.dev11}/src/upgini/utils/cv_utils.py +0 -0
  69. {upgini-1.2.167a4220.dev9 → upgini-1.2.167a4220.dev11}/src/upgini/utils/datetime_utils.py +0 -0
  70. {upgini-1.2.167a4220.dev9 → upgini-1.2.167a4220.dev11}/src/upgini/utils/deduplicate_utils.py +0 -0
  71. {upgini-1.2.167a4220.dev9 → upgini-1.2.167a4220.dev11}/src/upgini/utils/display_utils.py +0 -0
  72. {upgini-1.2.167a4220.dev9 → upgini-1.2.167a4220.dev11}/src/upgini/utils/email_utils.py +0 -0
  73. {upgini-1.2.167a4220.dev9 → upgini-1.2.167a4220.dev11}/src/upgini/utils/fallback_progress_bar.py +0 -0
  74. {upgini-1.2.167a4220.dev9 → upgini-1.2.167a4220.dev11}/src/upgini/utils/feature_info.py +0 -0
  75. {upgini-1.2.167a4220.dev9 → upgini-1.2.167a4220.dev11}/src/upgini/utils/features_validator.py +0 -0
  76. {upgini-1.2.167a4220.dev9 → upgini-1.2.167a4220.dev11}/src/upgini/utils/format.py +0 -0
  77. {upgini-1.2.167a4220.dev9 → upgini-1.2.167a4220.dev11}/src/upgini/utils/hash_utils.py +0 -0
  78. {upgini-1.2.167a4220.dev9 → upgini-1.2.167a4220.dev11}/src/upgini/utils/ip_utils.py +0 -0
  79. {upgini-1.2.167a4220.dev9 → upgini-1.2.167a4220.dev11}/src/upgini/utils/mstats.py +0 -0
  80. {upgini-1.2.167a4220.dev9 → upgini-1.2.167a4220.dev11}/src/upgini/utils/one_hot_encoder.py +0 -0
  81. {upgini-1.2.167a4220.dev9 → upgini-1.2.167a4220.dev11}/src/upgini/utils/phone_utils.py +0 -0
  82. {upgini-1.2.167a4220.dev9 → upgini-1.2.167a4220.dev11}/src/upgini/utils/postal_code_utils.py +0 -0
  83. {upgini-1.2.167a4220.dev9 → upgini-1.2.167a4220.dev11}/src/upgini/utils/progress_bar.py +0 -0
  84. {upgini-1.2.167a4220.dev9 → upgini-1.2.167a4220.dev11}/src/upgini/utils/psi.py +0 -0
  85. {upgini-1.2.167a4220.dev9 → upgini-1.2.167a4220.dev11}/src/upgini/utils/pyarrow_utils.py +0 -0
  86. {upgini-1.2.167a4220.dev9 → upgini-1.2.167a4220.dev11}/src/upgini/utils/sample_utils.py +0 -0
  87. {upgini-1.2.167a4220.dev9 → upgini-1.2.167a4220.dev11}/src/upgini/utils/search_key_derivations.py +0 -0
  88. {upgini-1.2.167a4220.dev9 → upgini-1.2.167a4220.dev11}/src/upgini/utils/sklearn_ext.py +0 -0
  89. {upgini-1.2.167a4220.dev9 → upgini-1.2.167a4220.dev11}/src/upgini/utils/sort.py +0 -0
  90. {upgini-1.2.167a4220.dev9 → upgini-1.2.167a4220.dev11}/src/upgini/utils/target_utils.py +0 -0
  91. {upgini-1.2.167a4220.dev9 → upgini-1.2.167a4220.dev11}/src/upgini/utils/track_info.py +0 -0
  92. {upgini-1.2.167a4220.dev9 → upgini-1.2.167a4220.dev11}/src/upgini/utils/ts_utils.py +0 -0
  93. {upgini-1.2.167a4220.dev9 → upgini-1.2.167a4220.dev11}/src/upgini/utils/warning_counter.py +0 -0
  94. {upgini-1.2.167a4220.dev9 → upgini-1.2.167a4220.dev11}/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.dev9
3
+ Version: 1.2.167a4220.dev11
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.dev11"
@@ -100,6 +100,7 @@ from upgini.utils.deduplicate_utils import (
100
100
  remove_fintech_duplicates,
101
101
  )
102
102
  from upgini.report.assemble import (
103
+ _fold_model_nodes,
103
104
  _generated_feature_names,
104
105
  _parse_generated_feature,
105
106
  assemble_report_data,
@@ -2513,20 +2514,81 @@ class FeaturesEnricher(TransformerMixin):
2513
2514
  return False
2514
2515
  return bool(aliases & self._column_name_aliases(list(ensemble_names), renaming))
2515
2516
 
2516
- def _match_autofe_feature_meta(
2517
+ def _match_autofe_node_meta(
2517
2518
  self,
2518
- generated_meta: GeneratedFeatureMetadata,
2519
+ autofe_feature: Feature,
2520
+ features_meta: list[FeaturesMetadataV2],
2521
+ used_names: set[str],
2522
+ ) -> FeaturesMetadataV2 | None:
2523
+ name = autofe_feature.get_display_name(shorten=True, unhash=True, cache=False)
2524
+ matched = self._match_autofe_meta_by_names({name} if name else set(), features_meta, used_names)
2525
+ if matched is not None or not name:
2526
+ return matched
2527
+ # Fold formulas omit display_index; FeaturesMetadata still uses the ordinary
2528
+ # get_display_name() with that index. Recover only the remainder after the
2529
+ # Feature-computed base name, then verify with get_display_name().
2530
+ prefix = name + "_"
2531
+ previous = autofe_feature.display_index
2532
+ try:
2533
+ for meta in features_meta or []:
2534
+ if meta.name in used_names or not meta.name.startswith(prefix):
2535
+ continue
2536
+ index = meta.name[len(prefix) :]
2537
+ if not index:
2538
+ continue
2539
+ if (
2540
+ autofe_feature.set_display_index(index).get_display_name(
2541
+ shorten=True, unhash=True, cache=False
2542
+ )
2543
+ == meta.name
2544
+ ):
2545
+ return meta
2546
+ finally:
2547
+ autofe_feature.set_display_index(previous)
2548
+ return None
2549
+
2550
+ @staticmethod
2551
+ def _match_autofe_meta_by_names(
2552
+ names: set[str],
2519
2553
  features_meta: list[FeaturesMetadataV2],
2520
2554
  used_names: set[str],
2521
2555
  ) -> FeaturesMetadataV2 | None:
2522
2556
  available = [meta for meta in features_meta or [] if meta.name not in used_names]
2523
2557
  by_name = {meta.name: meta for meta in available}
2524
- for name in _generated_feature_names(generated_meta):
2558
+ for name in names:
2525
2559
  matched = by_name.get(name)
2526
2560
  if matched is not None:
2527
2561
  return matched
2528
2562
  return None
2529
2563
 
2564
+ def _add_autofe_description(
2565
+ self,
2566
+ descriptions: list[dict],
2567
+ used_names: set[str],
2568
+ feature_meta: FeaturesMetadataV2 | None,
2569
+ source_names: list[str],
2570
+ operand_names: Iterable[str],
2571
+ ) -> None:
2572
+ if feature_meta is None or not feature_meta.shap_value:
2573
+ return
2574
+ description: dict = {}
2575
+ description["shap"] = feature_meta.shap_value
2576
+ source = feature_meta.data_source or ""
2577
+ description[self.bundle.get("autofe_descriptions_sources")] = source.replace(
2578
+ "AutoFE: features from ", ""
2579
+ ).replace("AutoFE: feature from ", "")
2580
+ description[self.bundle.get("autofe_descriptions_feature_name")] = feature_meta.name
2581
+ feature_idx = 1
2582
+ for hashed in source_names:
2583
+ description[self.bundle.get("autofe_descriptions_feature").format(feature_idx)] = hashed
2584
+ feature_idx += 1
2585
+ # Don't show features with more than 2 base columns
2586
+ if feature_idx > 3:
2587
+ return
2588
+ description[self.bundle.get("autofe_descriptions_function")] = ",".join(sorted(operand_names))
2589
+ used_names.add(feature_meta.name)
2590
+ descriptions.append(description)
2591
+
2530
2592
  def _ensemble_generated_metadata(self):
2531
2593
  autofe_meta = self._search_task.get_autofe_metadata() if self._search_task else None
2532
2594
  if not autofe_meta:
@@ -5995,9 +6057,11 @@ if response.status_code == 200:
5995
6057
  orig_to_hashed = {
5996
6058
  base_column.original_name: base_column.hashed_name for base_column in m.base_columns
5997
6059
  }
6060
+ parsed = Feature.from_formula(m.formula)
6061
+ if not isinstance(parsed, Feature):
6062
+ continue
5998
6063
  autofe_feature = (
5999
- Feature.from_formula(m.formula)
6000
- .set_display_index(m.display_index or None)
6064
+ parsed.set_display_index(m.display_index or None)
6001
6065
  .set_alias(m.alias)
6002
6066
  .set_op_params(m.operator_params or {})
6003
6067
  .rename_columns(orig_to_hashed)
@@ -6006,40 +6070,48 @@ if response.status_code == 200:
6006
6070
  self.logger.exception(f"Failed to parse AutoFE formula: {m.formula}")
6007
6071
  continue
6008
6072
 
6009
- is_ts = isinstance(autofe_feature.op, TimeSeriesBase)
6010
-
6011
- if autofe_feature.op.is_vector and not is_ts:
6012
- continue
6013
-
6014
- feature_meta = self._match_autofe_feature_meta(m, features_meta, used_names)
6015
- if feature_meta is None or not feature_meta.shap_value:
6073
+ fold_autofe = [
6074
+ child
6075
+ for fold in _fold_model_nodes(autofe_feature)
6076
+ for child in fold.children
6077
+ if isinstance(child, Feature)
6078
+ and (not child.op.is_vector or isinstance(child.op, TimeSeriesBase))
6079
+ ]
6080
+ if fold_autofe:
6081
+ hashed_to_orig = {hashed: orig for orig, hashed in orig_to_hashed.items()}
6082
+ for node in fold_autofe:
6083
+ is_ts = isinstance(node.op, TimeSeriesBase)
6084
+ source_names = [
6085
+ hashed
6086
+ for hashed in node.get_columns()
6087
+ if not is_ts or hashed_to_orig.get(hashed, hashed) not in self.fit_search_keys
6088
+ ]
6089
+ self._add_autofe_description(
6090
+ descriptions,
6091
+ used_names,
6092
+ self._match_autofe_node_meta(node, features_meta, used_names),
6093
+ source_names,
6094
+ node.get_all_operand_names(),
6095
+ )
6016
6096
  continue
6017
6097
 
6018
- description = {}
6019
- description["shap"] = feature_meta.shap_value
6020
- source = feature_meta.data_source or ""
6021
- description[self.bundle.get("autofe_descriptions_sources")] = source.replace(
6022
- "AutoFE: features from ", ""
6023
- ).replace("AutoFE: feature from ", "")
6024
- description[self.bundle.get("autofe_descriptions_feature_name")] = feature_meta.name
6025
-
6026
- feature_idx = 1
6027
- for bc in m.base_columns:
6028
- if not is_ts or bc.original_name not in self.fit_search_keys:
6029
- description[self.bundle.get("autofe_descriptions_feature").format(feature_idx)] = bc.hashed_name
6030
- feature_idx += 1
6031
-
6032
- # Don't show features with more than 2 base columns
6033
- if feature_idx > 3:
6098
+ is_ts = isinstance(autofe_feature.op, TimeSeriesBase)
6099
+ if autofe_feature.op.is_vector and not is_ts:
6034
6100
  continue
6035
6101
 
6036
- description[self.bundle.get("autofe_descriptions_function")] = ",".join(
6037
- sorted(autofe_feature.get_all_operand_names())
6102
+ source_names = [
6103
+ bc.hashed_name
6104
+ for bc in m.base_columns
6105
+ if not is_ts or bc.original_name not in self.fit_search_keys
6106
+ ]
6107
+ self._add_autofe_description(
6108
+ descriptions,
6109
+ used_names,
6110
+ self._match_autofe_meta_by_names(_generated_feature_names(m), features_meta, used_names),
6111
+ source_names,
6112
+ autofe_feature.get_all_operand_names(),
6038
6113
  )
6039
6114
 
6040
- used_names.add(feature_meta.name)
6041
- descriptions.append(description)
6042
-
6043
6115
  if len(descriptions) == 0:
6044
6116
  return None
6045
6117
 
@@ -6216,6 +6288,8 @@ if response.status_code == 200:
6216
6288
  display_html_dataframe(self.metrics, self.metrics, msg)
6217
6289
 
6218
6290
  def __show_selected_features(self):
6291
+ if self._has_single_ensemble_score():
6292
+ return
6219
6293
  try:
6220
6294
  _ = get_ipython() # type: ignore
6221
6295
 
@@ -7,7 +7,7 @@ from typing import Callable, Optional
7
7
  import pandas as pd
8
8
 
9
9
  from upgini.__about__ import __version__
10
- from upgini.autofe.feature import Feature
10
+ from upgini.autofe.feature import Column, Feature
11
11
  from upgini.autofe.vector import CatboostModel, EnsembleModel, OnnxModel
12
12
  from upgini.metadata import BaseColumnMetadata, FeaturesMetadataV2, GeneratedFeatureMetadata
13
13
  from upgini.report.data import (
@@ -313,26 +313,30 @@ def _overview_summary(
313
313
  joined_ads_features_count: Optional[int],
314
314
  joined_ads_count: Optional[int],
315
315
  ) -> SearchResultsSummary:
316
- top_level = _unique_fold_model_columns(generated_features)
316
+ top_level = _unique_fold_model_inputs(generated_features)
317
317
  features_by_name = {meta.name: meta for meta in features_meta}
318
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)
319
321
  if top_level:
320
322
  counts = {"external": 0, "original": 0, "autofe": 0}
321
- ads_ids: set[str] = set()
322
323
  psis: list[Optional[float]] = []
323
- for column in top_level:
324
- kind = _top_level_kind(column, features_by_name, autofe_by_name, is_ensemble)
324
+ for node in top_level:
325
+ kind = _top_level_kind(node, features_by_name, columns_by_name, autofe_by_name, is_ensemble)
325
326
  counts[kind] += 1
326
- ads_ids.update(_leaf_ads_ids(column, autofe_by_name))
327
- meta = features_by_name.get(column.hashed_name) or features_by_name.get(column.original_name)
327
+ meta = _feature_meta_for_node(node, features_by_name, columns_by_name)
328
328
  psis.append(None if meta is None else meta.psi_value)
329
329
  model_features = len(top_level)
330
330
  external, original, autofe = counts["external"], counts["original"], counts["autofe"]
331
- contributed = len(ads_ids)
331
+ contributed = len(ads_ids or set())
332
332
  else:
333
333
  model_features = len(model_rows) or None
334
334
  external = original = autofe = None
335
- contributed = len({(row.provider, row.source) for row in found_rows if row.provider})
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
+ )
336
340
  psis = [row.psi for row in model_rows]
337
341
  share, stable, psi_count = _stability_from_psi(psis)
338
342
  return SearchResultsSummary(
@@ -351,17 +355,66 @@ def _overview_summary(
351
355
  )
352
356
 
353
357
 
354
- def _unique_fold_model_columns(generated_features: list[GeneratedFeatureMetadata]) -> list[BaseColumnMetadata]:
355
- unique: dict[str, BaseColumnMetadata] = {}
358
+ def _unique_fold_model_inputs(generated_features: list[GeneratedFeatureMetadata]) -> list[Column | Feature]:
359
+ unique: dict[str, Column | Feature] = {}
356
360
  for meta in generated_features:
357
361
  feature = _parse_generated_feature(meta)
358
- if feature is None or not isinstance(feature.op, (CatboostModel, OnnxModel)):
362
+ if feature is None:
359
363
  continue
360
- for column in meta.base_columns:
361
- unique.setdefault(column.hashed_name or column.original_name, column)
364
+ for fold in _fold_model_nodes(feature):
365
+ for child in fold.children:
366
+ unique.setdefault(_top_level_key(child), child)
362
367
  return list(unique.values())
363
368
 
364
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
+
365
418
  def _autofe_generated_by_name(
366
419
  generated_features: list[GeneratedFeatureMetadata], is_ensemble: Callable[[str], bool]
367
420
  ) -> dict[str, GeneratedFeatureMetadata]:
@@ -400,13 +453,16 @@ def _generated_feature_names(meta: GeneratedFeatureMetadata) -> set[str]:
400
453
 
401
454
 
402
455
  def _top_level_kind(
403
- column: BaseColumnMetadata,
456
+ node: Column | Feature,
404
457
  features_by_name: dict[str, FeaturesMetadataV2],
458
+ columns_by_name: dict[str, BaseColumnMetadata],
405
459
  autofe_by_name: dict[str, GeneratedFeatureMetadata],
406
460
  is_ensemble: Callable[[str], bool],
407
461
  ) -> str:
408
- names = [column.hashed_name, column.original_name]
409
- if any(name in autofe_by_name for name in names if name):
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):
410
466
  return "autofe"
411
467
  meta = next((features_by_name[name] for name in names if name in features_by_name), None)
412
468
  if meta is not None and not is_ensemble(meta.name):
@@ -416,11 +472,28 @@ def _top_level_kind(
416
472
  return "external"
417
473
  if meta.source == "etalon":
418
474
  return "original"
419
- if column.ads_definition_id:
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:
420
477
  return "external"
421
478
  return "original"
422
479
 
423
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
+
424
497
  def _leaf_ads_ids(column: BaseColumnMetadata, autofe_by_name: dict[str, GeneratedFeatureMetadata]) -> set[str]:
425
498
  autofe = autofe_by_name.get(column.hashed_name) or autofe_by_name.get(column.original_name)
426
499
  leaves = autofe.base_columns if autofe is not None else [column]
@@ -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{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}
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>
@@ -510,9 +511,9 @@ const REPORT_DATA = __REPORT_DATA__;
510
511
  const maxImportance = Math.max(...features.map(f => f.importance)) || 1;
511
512
  const list = shapAll ? features : features.slice(0, (D.shap && D.shap.topN) || features.length);
512
513
  document.getElementById('shapList').innerHTML = list.map((f, i) =>
513
- `<div class="shap-row"><div><b>#${i + 1} ${f.name}</b>${providerBadge(f.provider)}</div>` +
514
+ `<div class="shap-row"><div class="shap-name"><b>#${i + 1} ${f.name}</b>${providerBadge(f.provider)}</div>` +
514
515
  `<div class="shap-track"><i class="shap-fill ${f.shapClass || 'upgini'}" style="width:${(f.importance / maxImportance * 100).toFixed(2)}%"></i></div>` +
515
- `<b>${fmt3(Math.abs(f.shap))}</b></div>`).join('');
516
+ `<b class="shap-value">${fmt3(Math.abs(f.shap))}</b></div>`).join('');
516
517
  document.getElementById('showAllShap').textContent = shapAll ? `Show top ${(D.shap && D.shap.topN) || features.length}` : `Show all ${features.length}`;
517
518
  }
518
519
 
@@ -1 +0,0 @@
1
- __version__ = "1.2.167.a4220.dev9"