upgini 1.2.167a4220.dev5__tar.gz → 1.2.167a4220.dev6__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 (95) hide show
  1. {upgini-1.2.167a4220.dev5 → upgini-1.2.167a4220.dev6}/PKG-INFO +1 -1
  2. upgini-1.2.167a4220.dev6/src/upgini/__about__.py +1 -0
  3. {upgini-1.2.167a4220.dev5 → upgini-1.2.167a4220.dev6}/src/upgini/features_enricher.py +37 -7
  4. upgini-1.2.167a4220.dev6/src/upgini/report/assemble.py +296 -0
  5. {upgini-1.2.167a4220.dev5 → upgini-1.2.167a4220.dev6}/src/upgini/report/data.py +2 -0
  6. {upgini-1.2.167a4220.dev5 → upgini-1.2.167a4220.dev6}/src/upgini/report/html.py +42 -8
  7. {upgini-1.2.167a4220.dev5 → upgini-1.2.167a4220.dev6}/src/upgini/report/template.html +1 -63
  8. upgini-1.2.167a4220.dev5/src/upgini/__about__.py +0 -1
  9. upgini-1.2.167a4220.dev5/src/upgini/report/assemble.py +0 -145
  10. {upgini-1.2.167a4220.dev5 → upgini-1.2.167a4220.dev6}/.gitignore +0 -0
  11. {upgini-1.2.167a4220.dev5 → upgini-1.2.167a4220.dev6}/LICENSE +0 -0
  12. {upgini-1.2.167a4220.dev5 → upgini-1.2.167a4220.dev6}/README.md +0 -0
  13. {upgini-1.2.167a4220.dev5 → upgini-1.2.167a4220.dev6}/pyproject.toml +0 -0
  14. {upgini-1.2.167a4220.dev5 → upgini-1.2.167a4220.dev6}/src/upgini/__init__.py +0 -0
  15. {upgini-1.2.167a4220.dev5 → upgini-1.2.167a4220.dev6}/src/upgini/ads.py +0 -0
  16. {upgini-1.2.167a4220.dev5 → upgini-1.2.167a4220.dev6}/src/upgini/ads_management/__init__.py +0 -0
  17. {upgini-1.2.167a4220.dev5 → upgini-1.2.167a4220.dev6}/src/upgini/ads_management/ads_manager.py +0 -0
  18. {upgini-1.2.167a4220.dev5 → upgini-1.2.167a4220.dev6}/src/upgini/autofe/__init__.py +0 -0
  19. {upgini-1.2.167a4220.dev5 → upgini-1.2.167a4220.dev6}/src/upgini/autofe/all_operators.py +0 -0
  20. {upgini-1.2.167a4220.dev5 → upgini-1.2.167a4220.dev6}/src/upgini/autofe/binary.py +0 -0
  21. {upgini-1.2.167a4220.dev5 → upgini-1.2.167a4220.dev6}/src/upgini/autofe/date.py +0 -0
  22. {upgini-1.2.167a4220.dev5 → upgini-1.2.167a4220.dev6}/src/upgini/autofe/feature.py +0 -0
  23. {upgini-1.2.167a4220.dev5 → upgini-1.2.167a4220.dev6}/src/upgini/autofe/groupby.py +0 -0
  24. {upgini-1.2.167a4220.dev5 → upgini-1.2.167a4220.dev6}/src/upgini/autofe/operand.py +0 -0
  25. {upgini-1.2.167a4220.dev5 → upgini-1.2.167a4220.dev6}/src/upgini/autofe/operator.py +0 -0
  26. {upgini-1.2.167a4220.dev5 → upgini-1.2.167a4220.dev6}/src/upgini/autofe/timeseries/__init__.py +0 -0
  27. {upgini-1.2.167a4220.dev5 → upgini-1.2.167a4220.dev6}/src/upgini/autofe/timeseries/base.py +0 -0
  28. {upgini-1.2.167a4220.dev5 → upgini-1.2.167a4220.dev6}/src/upgini/autofe/timeseries/cross.py +0 -0
  29. {upgini-1.2.167a4220.dev5 → upgini-1.2.167a4220.dev6}/src/upgini/autofe/timeseries/delta.py +0 -0
  30. {upgini-1.2.167a4220.dev5 → upgini-1.2.167a4220.dev6}/src/upgini/autofe/timeseries/lag.py +0 -0
  31. {upgini-1.2.167a4220.dev5 → upgini-1.2.167a4220.dev6}/src/upgini/autofe/timeseries/numpy_kernels.py +0 -0
  32. {upgini-1.2.167a4220.dev5 → upgini-1.2.167a4220.dev6}/src/upgini/autofe/timeseries/roll.py +0 -0
  33. {upgini-1.2.167a4220.dev5 → upgini-1.2.167a4220.dev6}/src/upgini/autofe/timeseries/trend.py +0 -0
  34. {upgini-1.2.167a4220.dev5 → upgini-1.2.167a4220.dev6}/src/upgini/autofe/timeseries/volatility.py +0 -0
  35. {upgini-1.2.167a4220.dev5 → upgini-1.2.167a4220.dev6}/src/upgini/autofe/unary.py +0 -0
  36. {upgini-1.2.167a4220.dev5 → upgini-1.2.167a4220.dev6}/src/upgini/autofe/utils.py +0 -0
  37. {upgini-1.2.167a4220.dev5 → upgini-1.2.167a4220.dev6}/src/upgini/autofe/vector.py +0 -0
  38. {upgini-1.2.167a4220.dev5 → upgini-1.2.167a4220.dev6}/src/upgini/data_source/__init__.py +0 -0
  39. {upgini-1.2.167a4220.dev5 → upgini-1.2.167a4220.dev6}/src/upgini/data_source/data_source_publisher.py +0 -0
  40. {upgini-1.2.167a4220.dev5 → upgini-1.2.167a4220.dev6}/src/upgini/dataset.py +0 -0
  41. {upgini-1.2.167a4220.dev5 → upgini-1.2.167a4220.dev6}/src/upgini/errors.py +0 -0
  42. {upgini-1.2.167a4220.dev5 → upgini-1.2.167a4220.dev6}/src/upgini/http.py +0 -0
  43. {upgini-1.2.167a4220.dev5 → upgini-1.2.167a4220.dev6}/src/upgini/mdc/__init__.py +0 -0
  44. {upgini-1.2.167a4220.dev5 → upgini-1.2.167a4220.dev6}/src/upgini/mdc/context.py +0 -0
  45. {upgini-1.2.167a4220.dev5 → upgini-1.2.167a4220.dev6}/src/upgini/metadata.py +0 -0
  46. {upgini-1.2.167a4220.dev5 → upgini-1.2.167a4220.dev6}/src/upgini/metrics.py +0 -0
  47. {upgini-1.2.167a4220.dev5 → upgini-1.2.167a4220.dev6}/src/upgini/normalizer/__init__.py +0 -0
  48. {upgini-1.2.167a4220.dev5 → upgini-1.2.167a4220.dev6}/src/upgini/normalizer/normalize_utils.py +0 -0
  49. {upgini-1.2.167a4220.dev5 → upgini-1.2.167a4220.dev6}/src/upgini/report/__init__.py +0 -0
  50. {upgini-1.2.167a4220.dev5 → upgini-1.2.167a4220.dev6}/src/upgini/report/stats.py +0 -0
  51. {upgini-1.2.167a4220.dev5 → upgini-1.2.167a4220.dev6}/src/upgini/resource_bundle/__init__.py +0 -0
  52. {upgini-1.2.167a4220.dev5 → upgini-1.2.167a4220.dev6}/src/upgini/resource_bundle/exceptions.py +0 -0
  53. {upgini-1.2.167a4220.dev5 → upgini-1.2.167a4220.dev6}/src/upgini/resource_bundle/strings.properties +0 -0
  54. {upgini-1.2.167a4220.dev5 → upgini-1.2.167a4220.dev6}/src/upgini/resource_bundle/strings_widget.properties +0 -0
  55. {upgini-1.2.167a4220.dev5 → upgini-1.2.167a4220.dev6}/src/upgini/sampler/__init__.py +0 -0
  56. {upgini-1.2.167a4220.dev5 → upgini-1.2.167a4220.dev6}/src/upgini/sampler/base.py +0 -0
  57. {upgini-1.2.167a4220.dev5 → upgini-1.2.167a4220.dev6}/src/upgini/sampler/random_under_sampler.py +0 -0
  58. {upgini-1.2.167a4220.dev5 → upgini-1.2.167a4220.dev6}/src/upgini/sampler/utils.py +0 -0
  59. {upgini-1.2.167a4220.dev5 → upgini-1.2.167a4220.dev6}/src/upgini/search_task.py +0 -0
  60. {upgini-1.2.167a4220.dev5 → upgini-1.2.167a4220.dev6}/src/upgini/spinner.py +0 -0
  61. {upgini-1.2.167a4220.dev5 → upgini-1.2.167a4220.dev6}/src/upgini/utils/Roboto-Regular.ttf +0 -0
  62. {upgini-1.2.167a4220.dev5 → upgini-1.2.167a4220.dev6}/src/upgini/utils/__init__.py +0 -0
  63. {upgini-1.2.167a4220.dev5 → upgini-1.2.167a4220.dev6}/src/upgini/utils/base_search_key_detector.py +0 -0
  64. {upgini-1.2.167a4220.dev5 → upgini-1.2.167a4220.dev6}/src/upgini/utils/blocked_time_series.py +0 -0
  65. {upgini-1.2.167a4220.dev5 → upgini-1.2.167a4220.dev6}/src/upgini/utils/config.py +0 -0
  66. {upgini-1.2.167a4220.dev5 → upgini-1.2.167a4220.dev6}/src/upgini/utils/country_utils.py +0 -0
  67. {upgini-1.2.167a4220.dev5 → upgini-1.2.167a4220.dev6}/src/upgini/utils/cpu_utils.py +0 -0
  68. {upgini-1.2.167a4220.dev5 → upgini-1.2.167a4220.dev6}/src/upgini/utils/custom_loss_utils.py +0 -0
  69. {upgini-1.2.167a4220.dev5 → upgini-1.2.167a4220.dev6}/src/upgini/utils/cv_utils.py +0 -0
  70. {upgini-1.2.167a4220.dev5 → upgini-1.2.167a4220.dev6}/src/upgini/utils/datetime_utils.py +0 -0
  71. {upgini-1.2.167a4220.dev5 → upgini-1.2.167a4220.dev6}/src/upgini/utils/deduplicate_utils.py +0 -0
  72. {upgini-1.2.167a4220.dev5 → upgini-1.2.167a4220.dev6}/src/upgini/utils/display_utils.py +0 -0
  73. {upgini-1.2.167a4220.dev5 → upgini-1.2.167a4220.dev6}/src/upgini/utils/email_utils.py +0 -0
  74. {upgini-1.2.167a4220.dev5 → upgini-1.2.167a4220.dev6}/src/upgini/utils/fallback_progress_bar.py +0 -0
  75. {upgini-1.2.167a4220.dev5 → upgini-1.2.167a4220.dev6}/src/upgini/utils/feature_info.py +0 -0
  76. {upgini-1.2.167a4220.dev5 → upgini-1.2.167a4220.dev6}/src/upgini/utils/features_validator.py +0 -0
  77. {upgini-1.2.167a4220.dev5 → upgini-1.2.167a4220.dev6}/src/upgini/utils/format.py +0 -0
  78. {upgini-1.2.167a4220.dev5 → upgini-1.2.167a4220.dev6}/src/upgini/utils/hash_utils.py +0 -0
  79. {upgini-1.2.167a4220.dev5 → upgini-1.2.167a4220.dev6}/src/upgini/utils/ip_utils.py +0 -0
  80. {upgini-1.2.167a4220.dev5 → upgini-1.2.167a4220.dev6}/src/upgini/utils/mstats.py +0 -0
  81. {upgini-1.2.167a4220.dev5 → upgini-1.2.167a4220.dev6}/src/upgini/utils/one_hot_encoder.py +0 -0
  82. {upgini-1.2.167a4220.dev5 → upgini-1.2.167a4220.dev6}/src/upgini/utils/phone_utils.py +0 -0
  83. {upgini-1.2.167a4220.dev5 → upgini-1.2.167a4220.dev6}/src/upgini/utils/postal_code_utils.py +0 -0
  84. {upgini-1.2.167a4220.dev5 → upgini-1.2.167a4220.dev6}/src/upgini/utils/progress_bar.py +0 -0
  85. {upgini-1.2.167a4220.dev5 → upgini-1.2.167a4220.dev6}/src/upgini/utils/psi.py +0 -0
  86. {upgini-1.2.167a4220.dev5 → upgini-1.2.167a4220.dev6}/src/upgini/utils/pyarrow_utils.py +0 -0
  87. {upgini-1.2.167a4220.dev5 → upgini-1.2.167a4220.dev6}/src/upgini/utils/sample_utils.py +0 -0
  88. {upgini-1.2.167a4220.dev5 → upgini-1.2.167a4220.dev6}/src/upgini/utils/search_key_derivations.py +0 -0
  89. {upgini-1.2.167a4220.dev5 → upgini-1.2.167a4220.dev6}/src/upgini/utils/sklearn_ext.py +0 -0
  90. {upgini-1.2.167a4220.dev5 → upgini-1.2.167a4220.dev6}/src/upgini/utils/sort.py +0 -0
  91. {upgini-1.2.167a4220.dev5 → upgini-1.2.167a4220.dev6}/src/upgini/utils/target_utils.py +0 -0
  92. {upgini-1.2.167a4220.dev5 → upgini-1.2.167a4220.dev6}/src/upgini/utils/track_info.py +0 -0
  93. {upgini-1.2.167a4220.dev5 → upgini-1.2.167a4220.dev6}/src/upgini/utils/ts_utils.py +0 -0
  94. {upgini-1.2.167a4220.dev5 → upgini-1.2.167a4220.dev6}/src/upgini/utils/warning_counter.py +0 -0
  95. {upgini-1.2.167a4220.dev5 → upgini-1.2.167a4220.dev6}/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.dev5
3
+ Version: 1.2.167a4220.dev6
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.dev6"
@@ -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
101
+ from upgini.report.assemble import assemble_report_data, autofe_rows_from_description, 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 (
@@ -2437,6 +2437,7 @@ class FeaturesEnricher(TransformerMixin):
2437
2437
  return max(self.__cached_sampled_datasets.values(), key=lambda item: len(item[0]) if item[0] is not None else 0)
2438
2438
 
2439
2439
  def _assemble_report_data(self):
2440
+ features, sources, model_shap, summary, autofe = self._report_search_results()
2440
2441
  search_id = self._search_task.search_task_id if self._search_task is not None else (self.search_id or "")
2441
2442
  return assemble_report_data(
2442
2443
  search_id=search_id,
@@ -2446,13 +2447,31 @@ class FeaturesEnricher(TransformerMixin):
2446
2447
  samples=self._report_sample_names(),
2447
2448
  metrics_df=self.metrics_raw if self.metrics_raw is not None else self.metrics,
2448
2449
  metric_name=self.metrics_metric_name,
2449
- model_features=self._ensemble_model_feature_count(),
2450
+ model_features=summary.model_features,
2450
2451
  is_binary=self.model_task_type == ModelTaskType.BINARY,
2451
2452
  dataset_samples=self._report_dataset_samples(),
2452
2453
  scored_samples=self._report_scored_samples(),
2453
2454
  bundle=self.bundle,
2455
+ summary=summary,
2456
+ features=features,
2457
+ sources=sources,
2458
+ autofe=autofe,
2459
+ model_feature_shap=model_shap,
2454
2460
  )
2455
2461
 
2462
+ def _report_search_results(self):
2463
+ features_meta = []
2464
+ if self._search_task is not None:
2465
+ features_meta = list(self._search_task.get_all_features_metadata_v2() or [])
2466
+ features, sources, model_shap, summary = build_search_results(
2467
+ model_names=self._ensemble_model_feature_names(),
2468
+ features_meta=features_meta,
2469
+ base_columns=self._ensemble_base_columns(),
2470
+ is_ensemble=self._is_ensemble_feature,
2471
+ )
2472
+ autofe = autofe_rows_from_description(self.get_autofe_features_description(), self.bundle)
2473
+ return features, sources, model_shap, summary, autofe
2474
+
2456
2475
  def _score_report_html(self) -> str | None:
2457
2476
  if self._search_task is None or not self._has_single_ensemble_score():
2458
2477
  return None
@@ -2493,13 +2512,24 @@ class FeaturesEnricher(TransformerMixin):
2493
2512
  names.add(meta.alias)
2494
2513
  return names
2495
2514
 
2496
- def _ensemble_model_feature_count(self) -> int | None:
2497
- names = {
2498
- column.original_name
2515
+ def _ensemble_base_columns(self) -> list[tuple[str, str]]:
2516
+ return [
2517
+ (column.original_name, column.hashed_name)
2499
2518
  for meta, _ in self._ensemble_generated_metadata()
2500
2519
  for column in meta.base_columns
2501
- }
2502
- return len(names) or None
2520
+ ]
2521
+
2522
+ def _ensemble_model_feature_names(self) -> list[str]:
2523
+ return sorted(
2524
+ {
2525
+ original
2526
+ for original, hashed in self._ensemble_base_columns()
2527
+ if not self._is_ensemble_feature(original) and not self._is_ensemble_feature(hashed)
2528
+ }
2529
+ )
2530
+
2531
+ def _ensemble_model_feature_count(self) -> int | None:
2532
+ return len(self._ensemble_model_feature_names()) or None
2503
2533
 
2504
2534
  def _psi_exceeds_threshold(self, psi: float | None, threshold: float) -> bool:
2505
2535
  return psi is not None and not pd.isna(psi) and psi > threshold
@@ -0,0 +1,296 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from datetime import datetime, timezone
5
+ from typing import Callable, Optional
6
+
7
+ import pandas as pd
8
+
9
+ from upgini.__about__ import __version__
10
+ from upgini.metadata import FeaturesMetadataV2
11
+ from upgini.report.data import (
12
+ FeatureRow,
13
+ ModelFeatureShap,
14
+ QualitySample,
15
+ ReportData,
16
+ ReportMetadata,
17
+ SampleStats,
18
+ SearchResultsSummary,
19
+ SourceRow,
20
+ )
21
+ from upgini.report.stats import PSI_CRITICAL, PSI_WARNING, compute_report_charts
22
+ from upgini.resource_bundle import ResourceBundle
23
+ from upgini.utils.feature_info import FeatureInfo
24
+
25
+ UPGINI_REPORT_BRANDING_URL = "UPGINI_REPORT_BRANDING_URL"
26
+ _STABILITY_PSI = 0.2
27
+
28
+
29
+ def format_search_duration(seconds: Optional[float]) -> Optional[str]:
30
+ if seconds is None:
31
+ return None
32
+ total = max(0, int(round(seconds)))
33
+ hours, remainder = divmod(total, 3600)
34
+ minutes, secs = divmod(remainder, 60)
35
+ if hours:
36
+ return f"{hours} h {minutes} min {secs} sec"
37
+ if minutes:
38
+ return f"{minutes} min {secs} sec"
39
+ return f"{secs} sec"
40
+
41
+
42
+ def assemble_report_data(
43
+ *,
44
+ search_id: str,
45
+ search_keys: list[str],
46
+ generated_at: Optional[datetime] = None,
47
+ pylib_version: Optional[str] = None,
48
+ search_duration_seconds: Optional[float] = None,
49
+ reference_rows: Optional[int] = None,
50
+ samples: Optional[list[str]] = None,
51
+ metrics_df: Optional[pd.DataFrame] = None,
52
+ metric_name: Optional[str] = None,
53
+ model_features: Optional[int] = None,
54
+ is_binary: bool = False,
55
+ dataset_samples: Optional[dict[str, pd.DataFrame]] = None,
56
+ scored_samples: Optional[dict[str, pd.DataFrame]] = None,
57
+ bundle: ResourceBundle,
58
+ summary: Optional[SearchResultsSummary] = None,
59
+ features: Optional[list[FeatureRow]] = None,
60
+ sources: Optional[list[SourceRow]] = None,
61
+ autofe: Optional[list[dict[str, str]]] = None,
62
+ model_feature_shap: Optional[list[ModelFeatureShap]] = None,
63
+ ) -> ReportData:
64
+ generated_at = generated_at or datetime.now(timezone.utc)
65
+ sample_names = list(samples or [])
66
+ chart_stats, charts = compute_report_charts(
67
+ sample_names=sample_names,
68
+ dataset_samples=dataset_samples,
69
+ scored_samples=scored_samples,
70
+ metric_name=metric_name,
71
+ is_binary=is_binary,
72
+ )
73
+ summary = summary or SearchResultsSummary(model_features=model_features)
74
+ if summary.model_features is None:
75
+ summary.model_features = model_features
76
+ return ReportData(
77
+ metadata=ReportMetadata(
78
+ search_id=search_id,
79
+ generated_at=generated_at.strftime("%Y-%m-%d %H:%M UTC"),
80
+ pylib_version=pylib_version or __version__,
81
+ search_keys=list(search_keys or []),
82
+ search_duration=format_search_duration(search_duration_seconds),
83
+ reference_rows=reference_rows,
84
+ samples=sample_names,
85
+ logo_url=_branding_url(),
86
+ ),
87
+ quality_by_sample=_quality_samples(metrics_df, metric_name, bundle),
88
+ summary=summary,
89
+ sample_stats=chart_stats or _sample_stats(sample_names, metrics_df, bundle),
90
+ is_binary=is_binary,
91
+ features=list(features or []),
92
+ sources=list(sources or []),
93
+ autofe=list(autofe or []),
94
+ model_feature_shap=list(model_feature_shap or []),
95
+ charts=charts,
96
+ )
97
+
98
+
99
+ def build_search_results(
100
+ *,
101
+ model_names: list[str],
102
+ features_meta: list[FeaturesMetadataV2],
103
+ base_columns: list[tuple[str, str]],
104
+ is_ensemble: Callable[[str], bool],
105
+ ) -> 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]
108
+ model_rows.sort(key=lambda row: (row.shap is None, -abs(row.shap or 0.0), row.name))
109
+ model_shap = [
110
+ ModelFeatureShap(
111
+ rank=index,
112
+ feature=row.name,
113
+ provider=row.provider,
114
+ mean_abs_shap=None if row.shap is None else abs(row.shap),
115
+ )
116
+ for index, row in enumerate(model_rows, start=1)
117
+ ]
118
+ join_rows = [
119
+ _feature_row(meta.name, meta)
120
+ for meta in features_meta
121
+ if meta.source != "etalon" and not is_ensemble(meta.name)
122
+ ]
123
+ sources = _source_rows(join_rows)
124
+ relevant = sum(1 for row in join_rows if _has_nonzero_shap(row))
125
+ contributed = len({(row.provider, row.source) for row in join_rows if row.provider and _has_nonzero_shap(row)})
126
+ model_count = len(model_names) or None
127
+ stable = sum(1 for row in model_rows if row.psi is not None and row.psi < _STABILITY_PSI)
128
+ summary = SearchResultsSummary(
129
+ relevant_features=relevant,
130
+ model_features=model_count,
131
+ data_sources=len(sources),
132
+ stable_features_share=(stable / len(model_rows)) if model_rows else None,
133
+ contributed_sources=contributed,
134
+ stable_features=stable if model_rows else None,
135
+ )
136
+ return model_rows, sources, model_shap, summary
137
+
138
+
139
+ def autofe_rows_from_description(df: Optional[pd.DataFrame], bundle: ResourceBundle) -> list[dict[str, str]]:
140
+ if df is None or df.empty:
141
+ return []
142
+ sources_col = bundle.get("autofe_descriptions_sources")
143
+ name_col = bundle.get("autofe_descriptions_feature_name")
144
+ func_col = bundle.get("autofe_descriptions_function")
145
+ rows: list[dict[str, str]] = []
146
+ for _, row in df.iterrows():
147
+ source_features = []
148
+ for index in (1, 2):
149
+ col = bundle.get("autofe_descriptions_feature").format(index)
150
+ value = _as_str(row[col]) if col in df.columns else None
151
+ if value:
152
+ source_features.append(value)
153
+ rows.append(
154
+ {
155
+ "sources": (_as_str(row[sources_col]) or "") if sources_col in df.columns else "",
156
+ "generatedFeature": (_as_str(row[name_col]) or "") if name_col in df.columns else "",
157
+ "sourceFeatures": ", ".join(source_features),
158
+ "functions": (_as_str(row[func_col]) or "") if func_col in df.columns else "",
159
+ }
160
+ )
161
+ return rows
162
+
163
+
164
+ def _branding_url() -> Optional[str]:
165
+ env_url = os.getenv(UPGINI_REPORT_BRANDING_URL)
166
+ if env_url and env_url.strip():
167
+ return env_url.strip()
168
+ return None
169
+
170
+
171
+ def _quality_samples(
172
+ metrics_df: Optional[pd.DataFrame], metric_name: Optional[str], bundle: ResourceBundle
173
+ ) -> list[QualitySample]:
174
+ if metrics_df is None or metrics_df.empty:
175
+ return []
176
+ segment_col = bundle.get("quality_metrics_segment_header")
177
+ uplift_col = bundle.get("quality_metrics_uplift_header")
178
+ uplift_pct_col = bundle.get("quality_metrics_uplift_perc_header")
179
+ baseline_col = bundle.get("quality_metrics_baseline_header").format(metric_name) if metric_name else None
180
+ enriched_col = bundle.get("quality_metrics_enriched_header").format(metric_name) if metric_name else None
181
+ baseline_std_col = bundle.get("quality_metrics_baseline_std_header").format(metric_name) if metric_name else None
182
+ enriched_std_col = bundle.get("quality_metrics_enriched_std_header").format(metric_name) if metric_name else None
183
+
184
+ samples: list[QualitySample] = []
185
+ for _, row in metrics_df.iterrows():
186
+ samples.append(
187
+ QualitySample(
188
+ evaluation_scope=(_as_str(row[segment_col]) or "") if segment_col in metrics_df.columns else "",
189
+ metric=metric_name or "",
190
+ baseline=_as_float(row[baseline_col]) if baseline_col in metrics_df.columns else None,
191
+ baseline_std=_as_float(row[baseline_std_col]) if baseline_std_col in metrics_df.columns else None,
192
+ enriched=_as_float(row[enriched_col]) if enriched_col in metrics_df.columns else None,
193
+ enriched_std=_as_float(row[enriched_std_col]) if enriched_std_col in metrics_df.columns else None,
194
+ uplift=_as_float(row[uplift_col]) if uplift_col in metrics_df.columns else None,
195
+ relative_uplift=_as_str(row[uplift_pct_col]) if uplift_pct_col in metrics_df.columns else None,
196
+ )
197
+ )
198
+ return samples
199
+
200
+
201
+ def _sample_stats(
202
+ sample_names: list[str], metrics_df: Optional[pd.DataFrame], bundle: ResourceBundle
203
+ ) -> list[SampleStats]:
204
+ segment_col = bundle.get("quality_metrics_segment_header")
205
+ rows_col = bundle.get("quality_metrics_rows_header")
206
+ mean_col = bundle.get("quality_metrics_mean_target_header")
207
+ by_name: dict[str, SampleStats] = {}
208
+ if metrics_df is not None and not metrics_df.empty and segment_col in metrics_df.columns:
209
+ for _, row in metrics_df.iterrows():
210
+ name = _as_str(row[segment_col])
211
+ if not name:
212
+ continue
213
+ by_name[name] = SampleStats(
214
+ sample=name,
215
+ rows=_as_int(row[rows_col]) if rows_col in metrics_df.columns else None,
216
+ mean_target=_as_float(row[mean_col]) if mean_col in metrics_df.columns else None,
217
+ )
218
+ names = sample_names or list(by_name)
219
+ stats = [by_name.get(name) or SampleStats(sample=name) for name in names]
220
+ seen = set(names)
221
+ stats.extend(sample for name, sample in by_name.items() if name not in seen)
222
+ return stats
223
+
224
+
225
+ def _as_str(value) -> Optional[str]:
226
+ return None if pd.isna(value) else str(value)
227
+
228
+
229
+ def _as_float(value) -> Optional[float]:
230
+ return None if pd.isna(value) else float(value)
231
+
232
+
233
+ def _as_int(value) -> Optional[int]:
234
+ return None if pd.isna(value) else int(value)
235
+
236
+
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:
251
+ if meta is None:
252
+ return FeatureRow(name=name)
253
+ info = FeatureInfo.from_metadata(meta, None, meta.source == "etalon", False)
254
+ return FeatureRow(
255
+ name=name,
256
+ shap=meta.shap_value,
257
+ psi=meta.psi_value,
258
+ drift=meta.drift_score,
259
+ coverage=meta.hit_rate,
260
+ provider=info.internal_provider,
261
+ source=info.internal_source,
262
+ stability_status=_psi_status(meta.psi_value),
263
+ )
264
+
265
+
266
+ def _source_rows(rows: list[FeatureRow]) -> list[SourceRow]:
267
+ grouped: dict[tuple[str, str], list[FeatureRow]] = {}
268
+ for row in rows:
269
+ if not row.provider:
270
+ continue
271
+ grouped.setdefault((row.provider, row.source), []).append(row)
272
+ sources = [
273
+ SourceRow(
274
+ provider=provider,
275
+ source=source,
276
+ shap_sum=sum(item.shap for item in items if item.shap is not None),
277
+ feature_count=len(items),
278
+ )
279
+ for (provider, source), items in grouped.items()
280
+ ]
281
+ sources.sort(key=lambda row: (-(row.shap_sum or 0.0), row.provider, row.source))
282
+ return sources
283
+
284
+
285
+ def _psi_status(psi: Optional[float]) -> Optional[str]:
286
+ if psi is None or pd.isna(psi):
287
+ return None
288
+ if psi > PSI_CRITICAL:
289
+ return "risk"
290
+ if psi >= PSI_WARNING:
291
+ return "watch"
292
+ return "good"
293
+
294
+
295
+ def _has_nonzero_shap(row: FeatureRow) -> bool:
296
+ return row.shap is not None and row.shap != 0
@@ -69,6 +69,8 @@ class SearchResultsSummary:
69
69
  model_features: Optional[int] = None
70
70
  data_sources: Optional[int] = None
71
71
  stable_features_share: Optional[float] = None
72
+ contributed_sources: Optional[int] = None
73
+ stable_features: Optional[int] = None
72
74
 
73
75
 
74
76
  @dataclass
@@ -4,7 +4,7 @@ import json
4
4
  from pathlib import Path
5
5
  from typing import Optional
6
6
 
7
- from upgini.report.data import ReportCharts, ReportData, SampleStats, SearchResultsSummary
7
+ from upgini.report.data import FeatureRow, ReportCharts, ReportData, SampleStats, SearchResultsSummary, SourceRow
8
8
  from upgini.report.stats import MEAN_AXIS_PADDING
9
9
 
10
10
  _TEMPLATE_PATH = Path(__file__).with_name("template.html")
@@ -37,15 +37,14 @@ def _report_payload(data: ReportData) -> dict:
37
37
  "scoreAnalysis": _score_analysis_payload(charts, samples),
38
38
  "scoreDistribution": _score_distribution_payload(charts, samples),
39
39
  "scoreStability": _score_stability_payload(charts, samples),
40
- "features": [],
41
- "featureStability": {"bySample": {}},
40
+ "features": _features_payload(data.features),
42
41
  "shap": {"topN": 5},
43
42
  "searchResults": {
44
43
  "relevantFeaturesCount": _dash(data.summary.relevant_features),
45
44
  "dataSourcesCount": _dash(data.summary.data_sources),
46
- "autofeCount": _dash(len(data.autofe) if data.autofe else None),
47
- "sources": [],
48
- "autofe": [],
45
+ "autofeCount": _dash(len(data.autofe)),
46
+ "sources": _sources_payload(data.sources),
47
+ "autofe": list(data.autofe),
49
48
  },
50
49
  }
51
50
 
@@ -141,11 +140,46 @@ def _summary_cards(summary: SearchResultsSummary) -> list[dict]:
141
140
  stability = None
142
141
  if summary.stable_features_share is not None:
143
142
  stability = f"{summary.stable_features_share * 100:.0f}%"
143
+ sources_caption = ""
144
+ if summary.contributed_sources is not None:
145
+ sources_caption = f"{summary.contributed_sources} contributed"
146
+ stability_caption = ""
147
+ if summary.stable_features is not None and summary.model_features is not None:
148
+ stability_caption = f"{summary.stable_features} of {summary.model_features} are stable"
144
149
  return [
145
150
  {"label": "Features found", "value": _dash(summary.relevant_features), "caption": ""},
146
151
  {"label": "Used in model", "value": _dash(summary.model_features), "caption": ""},
147
- {"label": "Data sources", "value": _dash(summary.data_sources), "caption": ""},
148
- {"label": "Stability", "value": _dash(stability), "caption": ""},
152
+ {"label": "Data sources", "value": _dash(summary.data_sources), "caption": sources_caption},
153
+ {"label": "Stability", "value": _dash(stability), "caption": stability_caption},
154
+ ]
155
+
156
+
157
+ def _features_payload(features: list[FeatureRow]) -> list[dict]:
158
+ return [
159
+ {
160
+ "name": row.name,
161
+ "provider": row.provider,
162
+ "source": row.source,
163
+ "importance": abs(row.shap) if row.shap is not None else 0,
164
+ "shap": row.shap,
165
+ "coverage": row.coverage,
166
+ "status": row.stability_status,
167
+ "psi": row.psi,
168
+ "drift": row.drift,
169
+ }
170
+ for row in features
171
+ ]
172
+
173
+
174
+ def _sources_payload(sources: list[SourceRow]) -> list[dict]:
175
+ return [
176
+ {
177
+ "provider": row.provider,
178
+ "source": row.source,
179
+ "aggregateShap": row.shap_sum,
180
+ "relevantFeatures": row.feature_count,
181
+ }
182
+ for row in sources
149
183
  ]
150
184
 
151
185
 
@@ -30,8 +30,6 @@
30
30
  <a class="nav-link" href="#score-stability"><span class="nav-dot"></span>Score stability</a>
31
31
  <a class="nav-link" href="#features-shap"><span class="nav-dot"></span>Features SHAP</a>
32
32
  <a class="nav-link" href="#search-results"><span class="nav-dot"></span>Search results</a>
33
- <a class="nav-link" href="#feature-stability"><span class="nav-dot"></span>Feature stability</a>
34
- <div class="run-note"><strong>Search completed</strong><span id="runNoteDuration"></span><br><span id="runNoteRows"></span></div>
35
33
  </aside>
36
34
  <main class="main">
37
35
  <section id="overview" class="section" style="margin-top:0">
@@ -87,11 +85,6 @@
87
85
  <div class="result-pane" id="result-sources"><div class="table-actions"><button class="button ghost copy-table" data-copy="sourceTable">Copy</button><button class="button ghost csv-table" data-table="sourceTable">Download CSV</button></div><div class="panel table-wrap"><table class="data-table"><thead><tr><th>Provider</th><th>Source</th><th>Aggregate SHAP</th><th>Relevant features</th><th>Action</th></tr></thead><tbody id="sourceTable"></tbody></table></div></div>
88
86
  <div class="result-pane" id="result-autofe"><div class="table-actions"><button class="button ghost copy-table" data-copy="autofeTable">Copy</button><button class="button ghost csv-table" data-table="autofeTable">Download CSV</button></div><div class="panel table-wrap"><table class="data-table"><thead><tr><th>Sources</th><th>Generated feature</th><th>Source features</th><th>Functions</th></tr></thead><tbody id="autofeTable"></tbody></table></div></div>
89
87
  </section>
90
-
91
- <section id="feature-stability" class="section">
92
- <div class="section-head"><div><h2>Feature stability</h2><p>Monthly PSI and direction-invariant univariate ROC-AUC for model features only.</p></div><div class="toolbar"><select id="stabilityFeature" class="control" aria-label="Model feature"></select><button class="button ghost chart-export" data-chart="featureStabilityChart">Save PNG</button></div></div>
93
- <div class="panel chart-panel"><div class="legend"><span><i style="background:#1645ee"></i>Monthly PSI</span><span><i style="background:#53cc61"></i>Univariate ROC-AUC</span></div><div class="canvas-wrap"><canvas id="featureStabilityChart" aria-label="Feature monthly PSI and ROC AUC" width="1596" height="560"></canvas><div class="chart-tooltip" id="featureTooltip"></div></div></div>
94
- </section>
95
88
  </main>
96
89
  </div>
97
90
  </div>
@@ -158,10 +151,6 @@ const REPORT_DATA = __REPORT_DATA__;
158
151
  });
159
152
  }
160
153
  setText('searchIdChip', 'search_id=' + m.searchId);
161
- const sources = m.sourcesCount == null ? '—' : m.sourcesCount;
162
- const rows = m.totalRows == null ? '—' : num(m.totalRows);
163
- setText('runNoteDuration', `${m.searchDuration || '—'} · ${sources} sources`);
164
- setText('runNoteRows', `${rows} rows processed`);
165
154
  setText('statusLine', m.statusLine);
166
155
  setText('heroTitle', m.reportTitle);
167
156
  setText('heroSubtitle', m.reportSubtitle);
@@ -551,48 +540,6 @@ const REPORT_DATA = __REPORT_DATA__;
551
540
  document.getElementById('emptyState').style.display = rows.length ? 'none' : 'block';
552
541
  }
553
542
 
554
- /* ---------------------------------------------------- feature stability */
555
- function renderFeatureSelect() {
556
- const s = document.getElementById('stabilityFeature');
557
- s.innerHTML = (D.features || []).map((f, i) => `<option value="${i}">#${i + 1} ${f.name}</option>`).join('');
558
- s.addEventListener('change', drawFeatureStability);
559
- }
560
- function currentStabilityFeature() {
561
- return D.features[+document.getElementById('stabilityFeature').value || 0];
562
- }
563
- function drawFeatureStability() {
564
- const cfg = D.featureStability || {};
565
- const { c, w, h } = setupCanvas('featureStabilityChart');
566
- const feature = currentStabilityFeature();
567
- const s = feature && cfg.bySample ? cfg.bySample[feature.name] : null;
568
- if (!feature || !s || !MONTH_COUNT) return;
569
- const psi = align(s.psi), auc = align(s.auc);
570
- hits.feature = [];
571
- const labelGutter = 46; // room for the ROC-AUC axis labels
572
- const seriesGutter = 22; // keeps the last point off those labels
573
- const plotRight = w - labelGutter - seriesGutter;
574
- const x = i => 48 + (MONTH_COUNT === 1 ? (plotRight - 48) / 2 : i * (plotRight - 48) / (MONTH_COUNT - 1));
575
- const yp = v => 18 + (cfg.psiAxisMax - v) / cfg.psiAxisMax * (h - 62);
576
- const ya = v => 18 + (cfg.aucAxis.max - v) / cfg.aucAxis.range * (h - 62);
577
-
578
- // grid + dual axis labels: PSI on the left, univariate ROC-AUC on the right
579
- c.font = '10px Inter, sans-serif';
580
- for (let i = 0; i < 5; i++) {
581
- const y = 18 + i * (h - 62) / 4;
582
- c.strokeStyle = '#e8eeec'; c.beginPath(); c.moveTo(40, y); c.lineTo(w - labelGutter, y); c.stroke();
583
- c.fillStyle = '#5f79c9';
584
- c.fillText((cfg.psiAxisMax - i * cfg.psiAxisMax / 4).toFixed(2), 4, y + 3);
585
- c.fillStyle = '#5aa86a';
586
- c.fillText((cfg.aucAxis.max - i * cfg.aucAxis.range / 4).toFixed(2), w - labelGutter + 10, y + 3);
587
- }
588
- strokeSeries(c, psi.map((v, i) => v == null ? null : { x: x(i), y: yp(v) }), '#1645ee', 4);
589
- strokeSeries(c, auc.map((v, i) => v == null ? null : { x: x(i), y: ya(v) }), '#53cc61', 4);
590
- MONTHS.forEach((m, i) => {
591
- hits.feature.push({ x: x(i), y: 18, w: 18, h: h - 55, index: i });
592
- c.fillStyle = '#7d8885'; c.fillText(m.split(' ')[0], x(i) - 8, h - 22); c.fillText(m.split(' ')[1], x(i) - 12, h - 9);
593
- });
594
- }
595
-
596
543
  /* -------------------------------------------------------------- tooltips */
597
544
  function bindTooltip(canvasId, tooltipId, hitKey, htmlFor) {
598
545
  const cv = document.getElementById(canvasId), tip = document.getElementById(tooltipId);
@@ -630,14 +577,6 @@ const REPORT_DATA = __REPORT_DATA__;
630
577
  return `<b>${MONTHS[i]} · ${selectedLabel('psiSample')}</b><br>PSI: ${fmt3(s.psi[i])}` +
631
578
  `<br>Rows: ${num(s.rows[i])}<br>Score coverage: ${s.scoreCoverage[i].toFixed(1)}%<br>Reference: first period (baseline month)`;
632
579
  });
633
- bindTooltip('featureStabilityChart', 'featureTooltip', 'feature', i => {
634
- const cfg = D.featureStability, f = currentStabilityFeature(), s = cfg.bySample[f.name];
635
- const auc = s.auc[i], raw = i % 3 === 0 ? 1 - auc : auc;
636
- return `<b>${f.name} · ${MONTHS[i]}</b><br>SHAP rank/value: #${D.features.indexOf(f) + 1} / ${fmt3(Math.abs(f.shap))}` +
637
- `<br>PSI: ${fmt3(s.psi[i])}<br>AUC: ${fmt3(auc)}<br>Raw AUC / direction: ${fmt3(raw)} / ${raw < .5 ? 'inverse' : 'direct'}` +
638
- `<br>Rows: ${num(s.rows[i])}<br>Labeled: ${num(s.labeled[i])}<br>Positive count: ${num(Math.round(s.labeled[i] * D.meta.targetRate))}` +
639
- `<br>Coverage: ${pct1(f.coverage)}<br>Missing rate: ${(100 - f.coverage).toFixed(1)}%`;
640
- });
641
580
  }
642
581
 
643
582
  /* -------------------------------------------------------- sample tooltip */
@@ -712,7 +651,7 @@ const REPORT_DATA = __REPORT_DATA__;
712
651
  });
713
652
  }
714
653
 
715
- function drawAll() { drawChart(); drawSample(); drawScore(); drawDistribution(); drawPsi(); drawFeatureStability(); }
654
+ function drawAll() { drawChart(); drawSample(); drawScore(); drawDistribution(); drawPsi(); }
716
655
 
717
656
  /* ------------------------------------------------------------------ init */
718
657
  renderHeader();
@@ -724,7 +663,6 @@ const REPORT_DATA = __REPORT_DATA__;
724
663
  renderSearchResults();
725
664
  renderFeatures();
726
665
  renderShap();
727
- renderFeatureSelect();
728
666
  bindControls();
729
667
  bindAllTooltips();
730
668
  bindSampleTooltip();
@@ -1 +0,0 @@
1
- __version__ = "1.2.167.a4220.dev5"
@@ -1,145 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import os
4
- from datetime import datetime, timezone
5
- from typing import Optional
6
-
7
- import pandas as pd
8
-
9
- from upgini.__about__ import __version__
10
- from upgini.report.data import QualitySample, ReportData, ReportMetadata, SampleStats, SearchResultsSummary
11
- from upgini.report.stats import compute_report_charts
12
- from upgini.resource_bundle import ResourceBundle
13
-
14
- UPGINI_REPORT_BRANDING_URL = "UPGINI_REPORT_BRANDING_URL"
15
-
16
-
17
- def format_search_duration(seconds: Optional[float]) -> Optional[str]:
18
- if seconds is None:
19
- return None
20
- total = max(0, int(round(seconds)))
21
- hours, remainder = divmod(total, 3600)
22
- minutes, secs = divmod(remainder, 60)
23
- if hours:
24
- return f"{hours} h {minutes} min {secs} sec"
25
- if minutes:
26
- return f"{minutes} min {secs} sec"
27
- return f"{secs} sec"
28
-
29
-
30
- def assemble_report_data(
31
- *,
32
- search_id: str,
33
- search_keys: list[str],
34
- generated_at: Optional[datetime] = None,
35
- pylib_version: Optional[str] = None,
36
- search_duration_seconds: Optional[float] = None,
37
- reference_rows: Optional[int] = None,
38
- samples: Optional[list[str]] = None,
39
- metrics_df: Optional[pd.DataFrame] = None,
40
- metric_name: Optional[str] = None,
41
- model_features: Optional[int] = None,
42
- is_binary: bool = False,
43
- dataset_samples: Optional[dict[str, pd.DataFrame]] = None,
44
- scored_samples: Optional[dict[str, pd.DataFrame]] = None,
45
- bundle: ResourceBundle,
46
- ) -> ReportData:
47
- generated_at = generated_at or datetime.now(timezone.utc)
48
- sample_names = list(samples or [])
49
- chart_stats, charts = compute_report_charts(
50
- sample_names=sample_names,
51
- dataset_samples=dataset_samples,
52
- scored_samples=scored_samples,
53
- metric_name=metric_name,
54
- is_binary=is_binary,
55
- )
56
- return ReportData(
57
- metadata=ReportMetadata(
58
- search_id=search_id,
59
- generated_at=generated_at.strftime("%Y-%m-%d %H:%M UTC"),
60
- pylib_version=pylib_version or __version__,
61
- search_keys=list(search_keys or []),
62
- search_duration=format_search_duration(search_duration_seconds),
63
- reference_rows=reference_rows,
64
- samples=sample_names,
65
- logo_url=_branding_url(),
66
- ),
67
- quality_by_sample=_quality_samples(metrics_df, metric_name, bundle),
68
- summary=SearchResultsSummary(model_features=model_features),
69
- sample_stats=chart_stats or _sample_stats(sample_names, metrics_df, bundle),
70
- is_binary=is_binary,
71
- charts=charts,
72
- )
73
-
74
-
75
- def _branding_url() -> Optional[str]:
76
- env_url = os.getenv(UPGINI_REPORT_BRANDING_URL)
77
- if env_url and env_url.strip():
78
- return env_url.strip()
79
- return None
80
-
81
-
82
- def _quality_samples(
83
- metrics_df: Optional[pd.DataFrame], metric_name: Optional[str], bundle: ResourceBundle
84
- ) -> list[QualitySample]:
85
- if metrics_df is None or metrics_df.empty:
86
- return []
87
- segment_col = bundle.get("quality_metrics_segment_header")
88
- uplift_col = bundle.get("quality_metrics_uplift_header")
89
- uplift_pct_col = bundle.get("quality_metrics_uplift_perc_header")
90
- baseline_col = bundle.get("quality_metrics_baseline_header").format(metric_name) if metric_name else None
91
- enriched_col = bundle.get("quality_metrics_enriched_header").format(metric_name) if metric_name else None
92
- baseline_std_col = bundle.get("quality_metrics_baseline_std_header").format(metric_name) if metric_name else None
93
- enriched_std_col = bundle.get("quality_metrics_enriched_std_header").format(metric_name) if metric_name else None
94
-
95
- samples: list[QualitySample] = []
96
- for _, row in metrics_df.iterrows():
97
- samples.append(
98
- QualitySample(
99
- evaluation_scope=(_as_str(row[segment_col]) or "") if segment_col in metrics_df.columns else "",
100
- metric=metric_name or "",
101
- baseline=_as_float(row[baseline_col]) if baseline_col in metrics_df.columns else None,
102
- baseline_std=_as_float(row[baseline_std_col]) if baseline_std_col in metrics_df.columns else None,
103
- enriched=_as_float(row[enriched_col]) if enriched_col in metrics_df.columns else None,
104
- enriched_std=_as_float(row[enriched_std_col]) if enriched_std_col in metrics_df.columns else None,
105
- uplift=_as_float(row[uplift_col]) if uplift_col in metrics_df.columns else None,
106
- relative_uplift=_as_str(row[uplift_pct_col]) if uplift_pct_col in metrics_df.columns else None,
107
- )
108
- )
109
- return samples
110
-
111
-
112
- def _sample_stats(
113
- sample_names: list[str], metrics_df: Optional[pd.DataFrame], bundle: ResourceBundle
114
- ) -> list[SampleStats]:
115
- segment_col = bundle.get("quality_metrics_segment_header")
116
- rows_col = bundle.get("quality_metrics_rows_header")
117
- mean_col = bundle.get("quality_metrics_mean_target_header")
118
- by_name: dict[str, SampleStats] = {}
119
- if metrics_df is not None and not metrics_df.empty and segment_col in metrics_df.columns:
120
- for _, row in metrics_df.iterrows():
121
- name = _as_str(row[segment_col])
122
- if not name:
123
- continue
124
- by_name[name] = SampleStats(
125
- sample=name,
126
- rows=_as_int(row[rows_col]) if rows_col in metrics_df.columns else None,
127
- mean_target=_as_float(row[mean_col]) if mean_col in metrics_df.columns else None,
128
- )
129
- names = sample_names or list(by_name)
130
- stats = [by_name.get(name) or SampleStats(sample=name) for name in names]
131
- seen = set(names)
132
- stats.extend(sample for name, sample in by_name.items() if name not in seen)
133
- return stats
134
-
135
-
136
- def _as_str(value) -> Optional[str]:
137
- return None if pd.isna(value) else str(value)
138
-
139
-
140
- def _as_float(value) -> Optional[float]:
141
- return None if pd.isna(value) else float(value)
142
-
143
-
144
- def _as_int(value) -> Optional[int]:
145
- return None if pd.isna(value) else int(value)