upgini 1.2.167a4220.dev4__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.dev4 → 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.dev4 → upgini-1.2.167a4220.dev6}/src/upgini/features_enricher.py +40 -7
  4. upgini-1.2.167a4220.dev6/src/upgini/report/assemble.py +296 -0
  5. {upgini-1.2.167a4220.dev4 → upgini-1.2.167a4220.dev6}/src/upgini/report/data.py +2 -0
  6. {upgini-1.2.167a4220.dev4 → upgini-1.2.167a4220.dev6}/src/upgini/report/html.py +43 -9
  7. {upgini-1.2.167a4220.dev4 → upgini-1.2.167a4220.dev6}/src/upgini/report/stats.py +10 -3
  8. {upgini-1.2.167a4220.dev4 → upgini-1.2.167a4220.dev6}/src/upgini/report/template.html +3 -65
  9. upgini-1.2.167a4220.dev4/src/upgini/__about__.py +0 -1
  10. upgini-1.2.167a4220.dev4/src/upgini/report/assemble.py +0 -145
  11. {upgini-1.2.167a4220.dev4 → upgini-1.2.167a4220.dev6}/.gitignore +0 -0
  12. {upgini-1.2.167a4220.dev4 → upgini-1.2.167a4220.dev6}/LICENSE +0 -0
  13. {upgini-1.2.167a4220.dev4 → upgini-1.2.167a4220.dev6}/README.md +0 -0
  14. {upgini-1.2.167a4220.dev4 → upgini-1.2.167a4220.dev6}/pyproject.toml +0 -0
  15. {upgini-1.2.167a4220.dev4 → upgini-1.2.167a4220.dev6}/src/upgini/__init__.py +0 -0
  16. {upgini-1.2.167a4220.dev4 → upgini-1.2.167a4220.dev6}/src/upgini/ads.py +0 -0
  17. {upgini-1.2.167a4220.dev4 → upgini-1.2.167a4220.dev6}/src/upgini/ads_management/__init__.py +0 -0
  18. {upgini-1.2.167a4220.dev4 → upgini-1.2.167a4220.dev6}/src/upgini/ads_management/ads_manager.py +0 -0
  19. {upgini-1.2.167a4220.dev4 → upgini-1.2.167a4220.dev6}/src/upgini/autofe/__init__.py +0 -0
  20. {upgini-1.2.167a4220.dev4 → upgini-1.2.167a4220.dev6}/src/upgini/autofe/all_operators.py +0 -0
  21. {upgini-1.2.167a4220.dev4 → upgini-1.2.167a4220.dev6}/src/upgini/autofe/binary.py +0 -0
  22. {upgini-1.2.167a4220.dev4 → upgini-1.2.167a4220.dev6}/src/upgini/autofe/date.py +0 -0
  23. {upgini-1.2.167a4220.dev4 → upgini-1.2.167a4220.dev6}/src/upgini/autofe/feature.py +0 -0
  24. {upgini-1.2.167a4220.dev4 → upgini-1.2.167a4220.dev6}/src/upgini/autofe/groupby.py +0 -0
  25. {upgini-1.2.167a4220.dev4 → upgini-1.2.167a4220.dev6}/src/upgini/autofe/operand.py +0 -0
  26. {upgini-1.2.167a4220.dev4 → upgini-1.2.167a4220.dev6}/src/upgini/autofe/operator.py +0 -0
  27. {upgini-1.2.167a4220.dev4 → upgini-1.2.167a4220.dev6}/src/upgini/autofe/timeseries/__init__.py +0 -0
  28. {upgini-1.2.167a4220.dev4 → upgini-1.2.167a4220.dev6}/src/upgini/autofe/timeseries/base.py +0 -0
  29. {upgini-1.2.167a4220.dev4 → upgini-1.2.167a4220.dev6}/src/upgini/autofe/timeseries/cross.py +0 -0
  30. {upgini-1.2.167a4220.dev4 → upgini-1.2.167a4220.dev6}/src/upgini/autofe/timeseries/delta.py +0 -0
  31. {upgini-1.2.167a4220.dev4 → upgini-1.2.167a4220.dev6}/src/upgini/autofe/timeseries/lag.py +0 -0
  32. {upgini-1.2.167a4220.dev4 → upgini-1.2.167a4220.dev6}/src/upgini/autofe/timeseries/numpy_kernels.py +0 -0
  33. {upgini-1.2.167a4220.dev4 → upgini-1.2.167a4220.dev6}/src/upgini/autofe/timeseries/roll.py +0 -0
  34. {upgini-1.2.167a4220.dev4 → upgini-1.2.167a4220.dev6}/src/upgini/autofe/timeseries/trend.py +0 -0
  35. {upgini-1.2.167a4220.dev4 → upgini-1.2.167a4220.dev6}/src/upgini/autofe/timeseries/volatility.py +0 -0
  36. {upgini-1.2.167a4220.dev4 → upgini-1.2.167a4220.dev6}/src/upgini/autofe/unary.py +0 -0
  37. {upgini-1.2.167a4220.dev4 → upgini-1.2.167a4220.dev6}/src/upgini/autofe/utils.py +0 -0
  38. {upgini-1.2.167a4220.dev4 → upgini-1.2.167a4220.dev6}/src/upgini/autofe/vector.py +0 -0
  39. {upgini-1.2.167a4220.dev4 → upgini-1.2.167a4220.dev6}/src/upgini/data_source/__init__.py +0 -0
  40. {upgini-1.2.167a4220.dev4 → upgini-1.2.167a4220.dev6}/src/upgini/data_source/data_source_publisher.py +0 -0
  41. {upgini-1.2.167a4220.dev4 → upgini-1.2.167a4220.dev6}/src/upgini/dataset.py +0 -0
  42. {upgini-1.2.167a4220.dev4 → upgini-1.2.167a4220.dev6}/src/upgini/errors.py +0 -0
  43. {upgini-1.2.167a4220.dev4 → upgini-1.2.167a4220.dev6}/src/upgini/http.py +0 -0
  44. {upgini-1.2.167a4220.dev4 → upgini-1.2.167a4220.dev6}/src/upgini/mdc/__init__.py +0 -0
  45. {upgini-1.2.167a4220.dev4 → upgini-1.2.167a4220.dev6}/src/upgini/mdc/context.py +0 -0
  46. {upgini-1.2.167a4220.dev4 → upgini-1.2.167a4220.dev6}/src/upgini/metadata.py +0 -0
  47. {upgini-1.2.167a4220.dev4 → upgini-1.2.167a4220.dev6}/src/upgini/metrics.py +0 -0
  48. {upgini-1.2.167a4220.dev4 → upgini-1.2.167a4220.dev6}/src/upgini/normalizer/__init__.py +0 -0
  49. {upgini-1.2.167a4220.dev4 → upgini-1.2.167a4220.dev6}/src/upgini/normalizer/normalize_utils.py +0 -0
  50. {upgini-1.2.167a4220.dev4 → upgini-1.2.167a4220.dev6}/src/upgini/report/__init__.py +0 -0
  51. {upgini-1.2.167a4220.dev4 → upgini-1.2.167a4220.dev6}/src/upgini/resource_bundle/__init__.py +0 -0
  52. {upgini-1.2.167a4220.dev4 → upgini-1.2.167a4220.dev6}/src/upgini/resource_bundle/exceptions.py +0 -0
  53. {upgini-1.2.167a4220.dev4 → upgini-1.2.167a4220.dev6}/src/upgini/resource_bundle/strings.properties +0 -0
  54. {upgini-1.2.167a4220.dev4 → upgini-1.2.167a4220.dev6}/src/upgini/resource_bundle/strings_widget.properties +0 -0
  55. {upgini-1.2.167a4220.dev4 → upgini-1.2.167a4220.dev6}/src/upgini/sampler/__init__.py +0 -0
  56. {upgini-1.2.167a4220.dev4 → upgini-1.2.167a4220.dev6}/src/upgini/sampler/base.py +0 -0
  57. {upgini-1.2.167a4220.dev4 → upgini-1.2.167a4220.dev6}/src/upgini/sampler/random_under_sampler.py +0 -0
  58. {upgini-1.2.167a4220.dev4 → upgini-1.2.167a4220.dev6}/src/upgini/sampler/utils.py +0 -0
  59. {upgini-1.2.167a4220.dev4 → upgini-1.2.167a4220.dev6}/src/upgini/search_task.py +0 -0
  60. {upgini-1.2.167a4220.dev4 → upgini-1.2.167a4220.dev6}/src/upgini/spinner.py +0 -0
  61. {upgini-1.2.167a4220.dev4 → upgini-1.2.167a4220.dev6}/src/upgini/utils/Roboto-Regular.ttf +0 -0
  62. {upgini-1.2.167a4220.dev4 → upgini-1.2.167a4220.dev6}/src/upgini/utils/__init__.py +0 -0
  63. {upgini-1.2.167a4220.dev4 → upgini-1.2.167a4220.dev6}/src/upgini/utils/base_search_key_detector.py +0 -0
  64. {upgini-1.2.167a4220.dev4 → upgini-1.2.167a4220.dev6}/src/upgini/utils/blocked_time_series.py +0 -0
  65. {upgini-1.2.167a4220.dev4 → upgini-1.2.167a4220.dev6}/src/upgini/utils/config.py +0 -0
  66. {upgini-1.2.167a4220.dev4 → upgini-1.2.167a4220.dev6}/src/upgini/utils/country_utils.py +0 -0
  67. {upgini-1.2.167a4220.dev4 → upgini-1.2.167a4220.dev6}/src/upgini/utils/cpu_utils.py +0 -0
  68. {upgini-1.2.167a4220.dev4 → upgini-1.2.167a4220.dev6}/src/upgini/utils/custom_loss_utils.py +0 -0
  69. {upgini-1.2.167a4220.dev4 → upgini-1.2.167a4220.dev6}/src/upgini/utils/cv_utils.py +0 -0
  70. {upgini-1.2.167a4220.dev4 → upgini-1.2.167a4220.dev6}/src/upgini/utils/datetime_utils.py +0 -0
  71. {upgini-1.2.167a4220.dev4 → upgini-1.2.167a4220.dev6}/src/upgini/utils/deduplicate_utils.py +0 -0
  72. {upgini-1.2.167a4220.dev4 → upgini-1.2.167a4220.dev6}/src/upgini/utils/display_utils.py +0 -0
  73. {upgini-1.2.167a4220.dev4 → upgini-1.2.167a4220.dev6}/src/upgini/utils/email_utils.py +0 -0
  74. {upgini-1.2.167a4220.dev4 → upgini-1.2.167a4220.dev6}/src/upgini/utils/fallback_progress_bar.py +0 -0
  75. {upgini-1.2.167a4220.dev4 → upgini-1.2.167a4220.dev6}/src/upgini/utils/feature_info.py +0 -0
  76. {upgini-1.2.167a4220.dev4 → upgini-1.2.167a4220.dev6}/src/upgini/utils/features_validator.py +0 -0
  77. {upgini-1.2.167a4220.dev4 → upgini-1.2.167a4220.dev6}/src/upgini/utils/format.py +0 -0
  78. {upgini-1.2.167a4220.dev4 → upgini-1.2.167a4220.dev6}/src/upgini/utils/hash_utils.py +0 -0
  79. {upgini-1.2.167a4220.dev4 → upgini-1.2.167a4220.dev6}/src/upgini/utils/ip_utils.py +0 -0
  80. {upgini-1.2.167a4220.dev4 → upgini-1.2.167a4220.dev6}/src/upgini/utils/mstats.py +0 -0
  81. {upgini-1.2.167a4220.dev4 → upgini-1.2.167a4220.dev6}/src/upgini/utils/one_hot_encoder.py +0 -0
  82. {upgini-1.2.167a4220.dev4 → upgini-1.2.167a4220.dev6}/src/upgini/utils/phone_utils.py +0 -0
  83. {upgini-1.2.167a4220.dev4 → upgini-1.2.167a4220.dev6}/src/upgini/utils/postal_code_utils.py +0 -0
  84. {upgini-1.2.167a4220.dev4 → upgini-1.2.167a4220.dev6}/src/upgini/utils/progress_bar.py +0 -0
  85. {upgini-1.2.167a4220.dev4 → upgini-1.2.167a4220.dev6}/src/upgini/utils/psi.py +0 -0
  86. {upgini-1.2.167a4220.dev4 → upgini-1.2.167a4220.dev6}/src/upgini/utils/pyarrow_utils.py +0 -0
  87. {upgini-1.2.167a4220.dev4 → upgini-1.2.167a4220.dev6}/src/upgini/utils/sample_utils.py +0 -0
  88. {upgini-1.2.167a4220.dev4 → upgini-1.2.167a4220.dev6}/src/upgini/utils/search_key_derivations.py +0 -0
  89. {upgini-1.2.167a4220.dev4 → upgini-1.2.167a4220.dev6}/src/upgini/utils/sklearn_ext.py +0 -0
  90. {upgini-1.2.167a4220.dev4 → upgini-1.2.167a4220.dev6}/src/upgini/utils/sort.py +0 -0
  91. {upgini-1.2.167a4220.dev4 → upgini-1.2.167a4220.dev6}/src/upgini/utils/target_utils.py +0 -0
  92. {upgini-1.2.167a4220.dev4 → upgini-1.2.167a4220.dev6}/src/upgini/utils/track_info.py +0 -0
  93. {upgini-1.2.167a4220.dev4 → upgini-1.2.167a4220.dev6}/src/upgini/utils/ts_utils.py +0 -0
  94. {upgini-1.2.167a4220.dev4 → upgini-1.2.167a4220.dev6}/src/upgini/utils/warning_counter.py +0 -0
  95. {upgini-1.2.167a4220.dev4 → 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.dev4
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 (
@@ -1462,6 +1462,9 @@ class FeaturesEnricher(TransformerMixin):
1462
1462
  progress_bar: bool = True,
1463
1463
  progress_callback: Callable | None = None,
1464
1464
  ):
1465
+ if self._has_single_ensemble_score():
1466
+ self.logger.info("Skip OOT PSI stability check for single ensemble score")
1467
+ return
1465
1468
  search_keys = self.search_keys.copy()
1466
1469
  search_keys.update(self._get_autodetected_search_keys())
1467
1470
  validated_X, _, validated_eval_set = self._validate_train_eval(X, y, eval_set)
@@ -2434,6 +2437,7 @@ class FeaturesEnricher(TransformerMixin):
2434
2437
  return max(self.__cached_sampled_datasets.values(), key=lambda item: len(item[0]) if item[0] is not None else 0)
2435
2438
 
2436
2439
  def _assemble_report_data(self):
2440
+ features, sources, model_shap, summary, autofe = self._report_search_results()
2437
2441
  search_id = self._search_task.search_task_id if self._search_task is not None else (self.search_id or "")
2438
2442
  return assemble_report_data(
2439
2443
  search_id=search_id,
@@ -2443,12 +2447,30 @@ class FeaturesEnricher(TransformerMixin):
2443
2447
  samples=self._report_sample_names(),
2444
2448
  metrics_df=self.metrics_raw if self.metrics_raw is not None else self.metrics,
2445
2449
  metric_name=self.metrics_metric_name,
2446
- model_features=self._ensemble_model_feature_count(),
2450
+ model_features=summary.model_features,
2447
2451
  is_binary=self.model_task_type == ModelTaskType.BINARY,
2448
2452
  dataset_samples=self._report_dataset_samples(),
2449
2453
  scored_samples=self._report_scored_samples(),
2450
2454
  bundle=self.bundle,
2455
+ summary=summary,
2456
+ features=features,
2457
+ sources=sources,
2458
+ autofe=autofe,
2459
+ model_feature_shap=model_shap,
2460
+ )
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,
2451
2471
  )
2472
+ autofe = autofe_rows_from_description(self.get_autofe_features_description(), self.bundle)
2473
+ return features, sources, model_shap, summary, autofe
2452
2474
 
2453
2475
  def _score_report_html(self) -> str | None:
2454
2476
  if self._search_task is None or not self._has_single_ensemble_score():
@@ -2490,13 +2512,24 @@ class FeaturesEnricher(TransformerMixin):
2490
2512
  names.add(meta.alias)
2491
2513
  return names
2492
2514
 
2493
- def _ensemble_model_feature_count(self) -> int | None:
2494
- names = {
2495
- column.original_name
2515
+ def _ensemble_base_columns(self) -> list[tuple[str, str]]:
2516
+ return [
2517
+ (column.original_name, column.hashed_name)
2496
2518
  for meta, _ in self._ensemble_generated_metadata()
2497
2519
  for column in meta.base_columns
2498
- }
2499
- 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
2500
2533
 
2501
2534
  def _psi_exceeds_threshold(self, psi: float | None, threshold: float) -> bool:
2502
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
 
@@ -79,7 +78,7 @@ def _samples(data: ReportData) -> list[dict]:
79
78
  {
80
79
  "id": _slug(name),
81
80
  "label": name,
82
- "caption": "",
81
+ "caption": "OOF validation" if index == 0 else "",
83
82
  "color": _SAMPLE_COLORS[index % len(_SAMPLE_COLORS)],
84
83
  }
85
84
  for index, name in enumerate(names)
@@ -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
 
@@ -344,10 +344,17 @@ def _score_psi(names: list[str], scored_samples: dict[str, pd.DataFrame], timeli
344
344
  if not timeline or not scored_samples:
345
345
  return []
346
346
  train = scored_samples.get(names[0]) if names else None
347
- if train is None or SCORE not in train.columns:
347
+ if train is None or SCORE not in train.columns or DATE not in train.columns:
348
348
  return []
349
- reference = pd.to_numeric(train[SCORE], errors="coerce").dropna()
350
- if reference.empty:
349
+ train_scores = pd.to_numeric(train[SCORE], errors="coerce")
350
+ train_months = _month_keys(train[DATE])
351
+ reference = None
352
+ for month in timeline:
353
+ month_scores = train_scores[train_months == month].dropna()
354
+ if not month_scores.empty:
355
+ reference = month_scores
356
+ break
357
+ if reference is None:
351
358
  return []
352
359
  bins = _get_bin_edges(reference, HISTOGRAM_BINS)
353
360
  rows = []
@@ -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">
@@ -71,7 +69,7 @@
71
69
  </section>
72
70
 
73
71
  <section id="score-stability" class="section">
74
- <div class="section-head"><div><h2>Score stability (PSI)</h2><p>Monthly PSI of scores · reference: full Train distribution · fixed reference bins.</p></div><div class="toolbar"><select id="psiSample" class="control" aria-label="Score stability sample"></select><button class="button ghost chart-export" data-chart="scorePsiChart">Save PNG</button></div></div>
72
+ <div class="section-head"><div><h2>Score stability (PSI)</h2><p>Monthly PSI of scores · reference: first period (baseline month) · fixed reference bins.</p></div><div class="toolbar"><select id="psiSample" class="control" aria-label="Score stability sample"></select><button class="button ghost chart-export" data-chart="scorePsiChart">Save PNG</button></div></div>
75
73
  <div class="panel chart-panel"><div class="threshold-legend" id="psiThresholdLegend"></div><div class="canvas-wrap"><canvas id="scorePsiChart" aria-label="Monthly score PSI" width="1596" height="560"></canvas><div class="chart-tooltip" id="psiTooltip"></div></div></div>
76
74
  </section>
77
75
 
@@ -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);
@@ -628,15 +575,7 @@ const REPORT_DATA = __REPORT_DATA__;
628
575
  bindTooltip('scorePsiChart', 'psiTooltip', 'psi', i => {
629
576
  const cfg = D.scoreStability, s = cfg.bySample[document.getElementById('psiSample').value];
630
577
  return `<b>${MONTHS[i]} · ${selectedLabel('psiSample')}</b><br>PSI: ${fmt3(s.psi[i])}` +
631
- `<br>Rows: ${num(s.rows[i])}<br>Score coverage: ${s.scoreCoverage[i].toFixed(1)}%<br>Reference: full Train distribution`;
632
- });
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)}%`;
578
+ `<br>Rows: ${num(s.rows[i])}<br>Score coverage: ${s.scoreCoverage[i].toFixed(1)}%<br>Reference: first period (baseline month)`;
640
579
  });
641
580
  }
642
581
 
@@ -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.dev4"
@@ -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)