upgini 1.2.167a4220.dev2__tar.gz → 1.2.167a4220.dev3__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.dev2 → upgini-1.2.167a4220.dev3}/PKG-INFO +1 -1
  2. {upgini-1.2.167a4220.dev2 → upgini-1.2.167a4220.dev3}/pyproject.toml +3 -0
  3. upgini-1.2.167a4220.dev3/src/upgini/__about__.py +1 -0
  4. {upgini-1.2.167a4220.dev2 → upgini-1.2.167a4220.dev3}/src/upgini/features_enricher.py +12 -23
  5. {upgini-1.2.167a4220.dev2 → upgini-1.2.167a4220.dev3}/src/upgini/report/assemble.py +2 -11
  6. {upgini-1.2.167a4220.dev2 → upgini-1.2.167a4220.dev3}/src/upgini/report/data.py +6 -1
  7. upgini-1.2.167a4220.dev3/src/upgini/report/html.py +167 -0
  8. upgini-1.2.167a4220.dev3/src/upgini/report/template.html +732 -0
  9. {upgini-1.2.167a4220.dev2 → upgini-1.2.167a4220.dev3}/src/upgini/utils/display_utils.py +31 -20
  10. upgini-1.2.167a4220.dev2/src/upgini/__about__.py +0 -1
  11. upgini-1.2.167a4220.dev2/src/upgini/report/html.py +0 -385
  12. {upgini-1.2.167a4220.dev2 → upgini-1.2.167a4220.dev3}/.gitignore +0 -0
  13. {upgini-1.2.167a4220.dev2 → upgini-1.2.167a4220.dev3}/LICENSE +0 -0
  14. {upgini-1.2.167a4220.dev2 → upgini-1.2.167a4220.dev3}/README.md +0 -0
  15. {upgini-1.2.167a4220.dev2 → upgini-1.2.167a4220.dev3}/src/upgini/__init__.py +0 -0
  16. {upgini-1.2.167a4220.dev2 → upgini-1.2.167a4220.dev3}/src/upgini/ads.py +0 -0
  17. {upgini-1.2.167a4220.dev2 → upgini-1.2.167a4220.dev3}/src/upgini/ads_management/__init__.py +0 -0
  18. {upgini-1.2.167a4220.dev2 → upgini-1.2.167a4220.dev3}/src/upgini/ads_management/ads_manager.py +0 -0
  19. {upgini-1.2.167a4220.dev2 → upgini-1.2.167a4220.dev3}/src/upgini/autofe/__init__.py +0 -0
  20. {upgini-1.2.167a4220.dev2 → upgini-1.2.167a4220.dev3}/src/upgini/autofe/all_operators.py +0 -0
  21. {upgini-1.2.167a4220.dev2 → upgini-1.2.167a4220.dev3}/src/upgini/autofe/binary.py +0 -0
  22. {upgini-1.2.167a4220.dev2 → upgini-1.2.167a4220.dev3}/src/upgini/autofe/date.py +0 -0
  23. {upgini-1.2.167a4220.dev2 → upgini-1.2.167a4220.dev3}/src/upgini/autofe/feature.py +0 -0
  24. {upgini-1.2.167a4220.dev2 → upgini-1.2.167a4220.dev3}/src/upgini/autofe/groupby.py +0 -0
  25. {upgini-1.2.167a4220.dev2 → upgini-1.2.167a4220.dev3}/src/upgini/autofe/operand.py +0 -0
  26. {upgini-1.2.167a4220.dev2 → upgini-1.2.167a4220.dev3}/src/upgini/autofe/operator.py +0 -0
  27. {upgini-1.2.167a4220.dev2 → upgini-1.2.167a4220.dev3}/src/upgini/autofe/timeseries/__init__.py +0 -0
  28. {upgini-1.2.167a4220.dev2 → upgini-1.2.167a4220.dev3}/src/upgini/autofe/timeseries/base.py +0 -0
  29. {upgini-1.2.167a4220.dev2 → upgini-1.2.167a4220.dev3}/src/upgini/autofe/timeseries/cross.py +0 -0
  30. {upgini-1.2.167a4220.dev2 → upgini-1.2.167a4220.dev3}/src/upgini/autofe/timeseries/delta.py +0 -0
  31. {upgini-1.2.167a4220.dev2 → upgini-1.2.167a4220.dev3}/src/upgini/autofe/timeseries/lag.py +0 -0
  32. {upgini-1.2.167a4220.dev2 → upgini-1.2.167a4220.dev3}/src/upgini/autofe/timeseries/numpy_kernels.py +0 -0
  33. {upgini-1.2.167a4220.dev2 → upgini-1.2.167a4220.dev3}/src/upgini/autofe/timeseries/roll.py +0 -0
  34. {upgini-1.2.167a4220.dev2 → upgini-1.2.167a4220.dev3}/src/upgini/autofe/timeseries/trend.py +0 -0
  35. {upgini-1.2.167a4220.dev2 → upgini-1.2.167a4220.dev3}/src/upgini/autofe/timeseries/volatility.py +0 -0
  36. {upgini-1.2.167a4220.dev2 → upgini-1.2.167a4220.dev3}/src/upgini/autofe/unary.py +0 -0
  37. {upgini-1.2.167a4220.dev2 → upgini-1.2.167a4220.dev3}/src/upgini/autofe/utils.py +0 -0
  38. {upgini-1.2.167a4220.dev2 → upgini-1.2.167a4220.dev3}/src/upgini/autofe/vector.py +0 -0
  39. {upgini-1.2.167a4220.dev2 → upgini-1.2.167a4220.dev3}/src/upgini/data_source/__init__.py +0 -0
  40. {upgini-1.2.167a4220.dev2 → upgini-1.2.167a4220.dev3}/src/upgini/data_source/data_source_publisher.py +0 -0
  41. {upgini-1.2.167a4220.dev2 → upgini-1.2.167a4220.dev3}/src/upgini/dataset.py +0 -0
  42. {upgini-1.2.167a4220.dev2 → upgini-1.2.167a4220.dev3}/src/upgini/errors.py +0 -0
  43. {upgini-1.2.167a4220.dev2 → upgini-1.2.167a4220.dev3}/src/upgini/http.py +0 -0
  44. {upgini-1.2.167a4220.dev2 → upgini-1.2.167a4220.dev3}/src/upgini/mdc/__init__.py +0 -0
  45. {upgini-1.2.167a4220.dev2 → upgini-1.2.167a4220.dev3}/src/upgini/mdc/context.py +0 -0
  46. {upgini-1.2.167a4220.dev2 → upgini-1.2.167a4220.dev3}/src/upgini/metadata.py +0 -0
  47. {upgini-1.2.167a4220.dev2 → upgini-1.2.167a4220.dev3}/src/upgini/metrics.py +0 -0
  48. {upgini-1.2.167a4220.dev2 → upgini-1.2.167a4220.dev3}/src/upgini/normalizer/__init__.py +0 -0
  49. {upgini-1.2.167a4220.dev2 → upgini-1.2.167a4220.dev3}/src/upgini/normalizer/normalize_utils.py +0 -0
  50. {upgini-1.2.167a4220.dev2 → upgini-1.2.167a4220.dev3}/src/upgini/report/__init__.py +0 -0
  51. {upgini-1.2.167a4220.dev2 → upgini-1.2.167a4220.dev3}/src/upgini/resource_bundle/__init__.py +0 -0
  52. {upgini-1.2.167a4220.dev2 → upgini-1.2.167a4220.dev3}/src/upgini/resource_bundle/exceptions.py +0 -0
  53. {upgini-1.2.167a4220.dev2 → upgini-1.2.167a4220.dev3}/src/upgini/resource_bundle/strings.properties +0 -0
  54. {upgini-1.2.167a4220.dev2 → upgini-1.2.167a4220.dev3}/src/upgini/resource_bundle/strings_widget.properties +0 -0
  55. {upgini-1.2.167a4220.dev2 → upgini-1.2.167a4220.dev3}/src/upgini/sampler/__init__.py +0 -0
  56. {upgini-1.2.167a4220.dev2 → upgini-1.2.167a4220.dev3}/src/upgini/sampler/base.py +0 -0
  57. {upgini-1.2.167a4220.dev2 → upgini-1.2.167a4220.dev3}/src/upgini/sampler/random_under_sampler.py +0 -0
  58. {upgini-1.2.167a4220.dev2 → upgini-1.2.167a4220.dev3}/src/upgini/sampler/utils.py +0 -0
  59. {upgini-1.2.167a4220.dev2 → upgini-1.2.167a4220.dev3}/src/upgini/search_task.py +0 -0
  60. {upgini-1.2.167a4220.dev2 → upgini-1.2.167a4220.dev3}/src/upgini/spinner.py +0 -0
  61. {upgini-1.2.167a4220.dev2 → upgini-1.2.167a4220.dev3}/src/upgini/utils/Roboto-Regular.ttf +0 -0
  62. {upgini-1.2.167a4220.dev2 → upgini-1.2.167a4220.dev3}/src/upgini/utils/__init__.py +0 -0
  63. {upgini-1.2.167a4220.dev2 → upgini-1.2.167a4220.dev3}/src/upgini/utils/base_search_key_detector.py +0 -0
  64. {upgini-1.2.167a4220.dev2 → upgini-1.2.167a4220.dev3}/src/upgini/utils/blocked_time_series.py +0 -0
  65. {upgini-1.2.167a4220.dev2 → upgini-1.2.167a4220.dev3}/src/upgini/utils/config.py +0 -0
  66. {upgini-1.2.167a4220.dev2 → upgini-1.2.167a4220.dev3}/src/upgini/utils/country_utils.py +0 -0
  67. {upgini-1.2.167a4220.dev2 → upgini-1.2.167a4220.dev3}/src/upgini/utils/cpu_utils.py +0 -0
  68. {upgini-1.2.167a4220.dev2 → upgini-1.2.167a4220.dev3}/src/upgini/utils/custom_loss_utils.py +0 -0
  69. {upgini-1.2.167a4220.dev2 → upgini-1.2.167a4220.dev3}/src/upgini/utils/cv_utils.py +0 -0
  70. {upgini-1.2.167a4220.dev2 → upgini-1.2.167a4220.dev3}/src/upgini/utils/datetime_utils.py +0 -0
  71. {upgini-1.2.167a4220.dev2 → upgini-1.2.167a4220.dev3}/src/upgini/utils/deduplicate_utils.py +0 -0
  72. {upgini-1.2.167a4220.dev2 → upgini-1.2.167a4220.dev3}/src/upgini/utils/email_utils.py +0 -0
  73. {upgini-1.2.167a4220.dev2 → upgini-1.2.167a4220.dev3}/src/upgini/utils/fallback_progress_bar.py +0 -0
  74. {upgini-1.2.167a4220.dev2 → upgini-1.2.167a4220.dev3}/src/upgini/utils/feature_info.py +0 -0
  75. {upgini-1.2.167a4220.dev2 → upgini-1.2.167a4220.dev3}/src/upgini/utils/features_validator.py +0 -0
  76. {upgini-1.2.167a4220.dev2 → upgini-1.2.167a4220.dev3}/src/upgini/utils/format.py +0 -0
  77. {upgini-1.2.167a4220.dev2 → upgini-1.2.167a4220.dev3}/src/upgini/utils/hash_utils.py +0 -0
  78. {upgini-1.2.167a4220.dev2 → upgini-1.2.167a4220.dev3}/src/upgini/utils/ip_utils.py +0 -0
  79. {upgini-1.2.167a4220.dev2 → upgini-1.2.167a4220.dev3}/src/upgini/utils/mstats.py +0 -0
  80. {upgini-1.2.167a4220.dev2 → upgini-1.2.167a4220.dev3}/src/upgini/utils/one_hot_encoder.py +0 -0
  81. {upgini-1.2.167a4220.dev2 → upgini-1.2.167a4220.dev3}/src/upgini/utils/phone_utils.py +0 -0
  82. {upgini-1.2.167a4220.dev2 → upgini-1.2.167a4220.dev3}/src/upgini/utils/postal_code_utils.py +0 -0
  83. {upgini-1.2.167a4220.dev2 → upgini-1.2.167a4220.dev3}/src/upgini/utils/progress_bar.py +0 -0
  84. {upgini-1.2.167a4220.dev2 → upgini-1.2.167a4220.dev3}/src/upgini/utils/psi.py +0 -0
  85. {upgini-1.2.167a4220.dev2 → upgini-1.2.167a4220.dev3}/src/upgini/utils/pyarrow_utils.py +0 -0
  86. {upgini-1.2.167a4220.dev2 → upgini-1.2.167a4220.dev3}/src/upgini/utils/sample_utils.py +0 -0
  87. {upgini-1.2.167a4220.dev2 → upgini-1.2.167a4220.dev3}/src/upgini/utils/search_key_derivations.py +0 -0
  88. {upgini-1.2.167a4220.dev2 → upgini-1.2.167a4220.dev3}/src/upgini/utils/sklearn_ext.py +0 -0
  89. {upgini-1.2.167a4220.dev2 → upgini-1.2.167a4220.dev3}/src/upgini/utils/sort.py +0 -0
  90. {upgini-1.2.167a4220.dev2 → upgini-1.2.167a4220.dev3}/src/upgini/utils/target_utils.py +0 -0
  91. {upgini-1.2.167a4220.dev2 → upgini-1.2.167a4220.dev3}/src/upgini/utils/track_info.py +0 -0
  92. {upgini-1.2.167a4220.dev2 → upgini-1.2.167a4220.dev3}/src/upgini/utils/ts_utils.py +0 -0
  93. {upgini-1.2.167a4220.dev2 → upgini-1.2.167a4220.dev3}/src/upgini/utils/warning_counter.py +0 -0
  94. {upgini-1.2.167a4220.dev2 → upgini-1.2.167a4220.dev3}/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.dev2
3
+ Version: 1.2.167a4220.dev3
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/
@@ -73,6 +73,9 @@ packages = [
73
73
  "src/upgini"
74
74
  ]
75
75
 
76
+ [tool.hatch.build.targets.wheel.force-include]
77
+ "src/upgini/report/template.html" = "upgini/report/template.html"
78
+
76
79
  [tool.hatch.build]
77
80
  include = [
78
81
  "/src/utils/Roboto-Regular.ttf",
@@ -0,0 +1 @@
1
+ __version__ = "1.2.167.a4220.dev3"
@@ -185,9 +185,6 @@ class FeaturesEnricher(TransformerMixin):
185
185
 
186
186
  shared_datasets: list of str, optional (default=None)
187
187
  list of private shared dataset ids for custom search
188
-
189
- reports_path: str, optional (default=None)
190
- Directory for the HTML search report. Defaults to a `reports` folder in the current working directory.
191
188
  """
192
189
 
193
190
  TARGET_NAME = "target"
@@ -265,7 +262,6 @@ class FeaturesEnricher(TransformerMixin):
265
262
  sample_config: SampleConfig | None = None,
266
263
  print_trace_id: bool = False,
267
264
  print_loaded_report: bool = True,
268
- reports_path: str | None = None,
269
265
  **kwargs,
270
266
  ):
271
267
  self.bundle = get_custom_bundle(custom_bundle_config)
@@ -335,9 +331,8 @@ class FeaturesEnricher(TransformerMixin):
335
331
  self.disable_force_downsampling = disable_force_downsampling
336
332
  self.print_trace_id = print_trace_id
337
333
  self.add_info: AddInfo | None = None
338
- self.reports_path = reports_path
339
334
  self.search_duration_seconds: float | None = None
340
- self.report_file_path: str | None = None
335
+ self.report_html: str | None = None
341
336
 
342
337
  if search_id:
343
338
  search_task = SearchTask(search_id, rest_client=self.rest_client, logger=self.logger)
@@ -2305,9 +2300,6 @@ class FeaturesEnricher(TransformerMixin):
2305
2300
  return None
2306
2301
  return ensemble[0]
2307
2302
 
2308
- def _reports_dir(self) -> Path:
2309
- return Path(self.reports_path) if self.reports_path else Path.cwd() / "reports"
2310
-
2311
2303
  def _reference_rows(self) -> int | None:
2312
2304
  if self.X is None:
2313
2305
  return None
@@ -2337,18 +2329,12 @@ class FeaturesEnricher(TransformerMixin):
2337
2329
  bundle=self.bundle,
2338
2330
  )
2339
2331
 
2340
- def _write_score_report(self) -> str | None:
2332
+ def _score_report_html(self) -> str | None:
2341
2333
  if self._search_task is None or not self._has_single_ensemble_score():
2342
2334
  return None
2343
2335
  report_data = self._assemble_report_data()
2344
- html = generate_html_report(report_data)
2345
- reports_dir = self._reports_dir()
2346
- reports_dir.mkdir(parents=True, exist_ok=True)
2347
- path = reports_dir / f"upgini-report-{report_data.metadata.search_id}.html"
2348
- path.write_text(html, encoding="utf-8")
2349
- self.report_file_path = str(path)
2350
- self.logger.info(f"Score report saved to {path}")
2351
- return self.report_file_path
2336
+ self.report_html = generate_html_report(report_data)
2337
+ return self.report_html
2352
2338
 
2353
2339
  def _is_ensemble_feature(self, column_name: str) -> bool:
2354
2340
  renaming = self.fit_columns_renaming or {}
@@ -4267,7 +4253,7 @@ if response.status_code == 200:
4267
4253
  self.metrics = None
4268
4254
  self.metrics_raw = None
4269
4255
  self.metrics_metric_name = None
4270
- self.report_file_path = None
4256
+ self.report_html = None
4271
4257
  self.fit_columns_renaming = None
4272
4258
  self.fit_dropped_features = set()
4273
4259
  self.fit_generated_features = []
@@ -6085,13 +6071,16 @@ if response.status_code == 200:
6085
6071
 
6086
6072
  def __show_report_button(self, display_id: str | None = None, display_handle=None):
6087
6073
  try:
6088
- report_path = self._write_score_report() or self.report_file_path
6089
- if report_path:
6074
+ report_html = self._score_report_html() or self.report_html
6075
+ if report_html:
6090
6076
  if not ipython_available():
6091
- print(f"Full report saved to {report_path}")
6092
6077
  return
6078
+ search_id = self._search_task.search_task_id if self._search_task is not None else (self.search_id or "")
6093
6079
  return show_button_open_report(
6094
- report_path, display_id=display_id, display_handle=display_handle
6080
+ report_html,
6081
+ download_name=f"upgini-report-{search_id}.html",
6082
+ display_id=display_id,
6083
+ display_handle=display_handle,
6095
6084
  )
6096
6085
 
6097
6086
  eval_sets_drift_df = self._get_eval_sets_drift_summary()
@@ -79,8 +79,9 @@ def _quality_samples(
79
79
  evaluation_scope=(_as_str(row[segment_col]) or "") if segment_col in metrics_df.columns else "",
80
80
  metric=metric_name or "",
81
81
  baseline=_as_float(row[baseline_col]) if baseline_col in metrics_df.columns else None,
82
+ baseline_std=_as_float(row[baseline_std_col]) if baseline_std_col in metrics_df.columns else None,
82
83
  enriched=_as_float(row[enriched_col]) if enriched_col in metrics_df.columns else None,
83
- std=_metric_std(row, metrics_df.columns, enriched_std_col, baseline_std_col),
84
+ enriched_std=_as_float(row[enriched_std_col]) if enriched_std_col in metrics_df.columns else None,
84
85
  uplift=_as_float(row[uplift_col]) if uplift_col in metrics_df.columns else None,
85
86
  relative_uplift=_as_str(row[uplift_pct_col]) if uplift_pct_col in metrics_df.columns else None,
86
87
  )
@@ -112,16 +113,6 @@ def _sample_stats(
112
113
  return stats
113
114
 
114
115
 
115
- def _metric_std(row, columns, enriched_std_col: Optional[str], baseline_std_col: Optional[str]) -> Optional[float]:
116
- if enriched_std_col in columns:
117
- std = _as_float(row[enriched_std_col])
118
- if std is not None:
119
- return std
120
- if baseline_std_col in columns:
121
- return _as_float(row[baseline_std_col])
122
- return None
123
-
124
-
125
116
  def _as_str(value) -> Optional[str]:
126
117
  return None if pd.isna(value) else str(value)
127
118
 
@@ -21,11 +21,16 @@ class QualitySample:
21
21
  evaluation_scope: str
22
22
  metric: str
23
23
  baseline: Optional[float] = None
24
+ baseline_std: Optional[float] = None
24
25
  enriched: Optional[float] = None
25
- std: Optional[float] = None
26
+ enriched_std: Optional[float] = None
26
27
  uplift: Optional[float] = None
27
28
  relative_uplift: Optional[str] = None
28
29
 
30
+ @property
31
+ def std(self) -> Optional[float]:
32
+ return self.enriched_std if self.enriched_std is not None else self.baseline_std
33
+
29
34
 
30
35
  @dataclass
31
36
  class SampleStats:
@@ -0,0 +1,167 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+ from upgini.report.data import ReportData, SampleStats, SearchResultsSummary
8
+
9
+ _TEMPLATE_PATH = Path(__file__).with_name("template.html")
10
+ _SAMPLE_COLORS = ("#1645ee", "#20aab4", "#53cc61", "#e19132", "#d45b5b")
11
+
12
+
13
+ def generate_html_report(data: ReportData) -> str:
14
+ template = _TEMPLATE_PATH.read_text(encoding="utf-8")
15
+ if "__REPORT_DATA__" not in template:
16
+ raise RuntimeError("Report template is missing the REPORT_DATA placeholder")
17
+ payload = json.dumps(_report_payload(data), ensure_ascii=False, indent=2)
18
+ payload = payload.replace("<", "\\u003c").replace(">", "\\u003e")
19
+ return template.replace("__REPORT_DATA__", payload, 1)
20
+
21
+
22
+ def _report_payload(data: ReportData) -> dict:
23
+ samples = _samples(data)
24
+ return {
25
+ "meta": _meta(data, samples),
26
+ "timeline": {"months": [], "periodOptions": [], "defaultPeriod": 0},
27
+ "samples": samples,
28
+ "keyResult": _key_result(data),
29
+ "summaryCards": _summary_cards(data.summary),
30
+ "sampleStats": _sample_stats_payload(data, samples),
31
+ "scoreAnalysis": {"bySample": {}},
32
+ "scoreDistribution": {"bySample": {}},
33
+ "scoreStability": {"bySample": {}},
34
+ "features": [],
35
+ "featureStability": {"bySample": {}},
36
+ "shap": {"topN": 5},
37
+ "searchResults": {
38
+ "relevantFeaturesCount": _dash(data.summary.relevant_features),
39
+ "dataSourcesCount": _dash(data.summary.data_sources),
40
+ "autofeCount": _dash(len(data.autofe) if data.autofe else None),
41
+ "sources": [],
42
+ "autofe": [],
43
+ },
44
+ }
45
+
46
+
47
+ def _meta(data: ReportData, samples: list[dict]) -> dict:
48
+ meta = data.metadata
49
+ payload = {
50
+ "reportTitle": "Feature enrichment report",
51
+ "reportSubtitle": (
52
+ "Impact of external data on model quality, score dynamics, and the stability of discovered features."
53
+ ),
54
+ "searchId": meta.search_id,
55
+ "generatedAt": meta.generated_at,
56
+ "pylibVersion": meta.pylib_version,
57
+ "searchKeys": list(meta.search_keys),
58
+ "sampleNames": [sample["id"] for sample in samples],
59
+ "searchDuration": meta.search_duration,
60
+ "sourcesCount": data.summary.data_sources,
61
+ "totalRows": meta.reference_rows,
62
+ "statusLine": "FIT completed successfully",
63
+ "downloadFileName": f"upgini-report-{meta.search_id}.html",
64
+ }
65
+ if meta.logo_url:
66
+ payload["partnerLogo"] = {"src": meta.logo_url, "alt": "", "height": 30}
67
+ return payload
68
+
69
+
70
+ def _samples(data: ReportData) -> list[dict]:
71
+ names = [stats.sample for stats in data.sample_stats] or list(data.metadata.samples)
72
+ return [
73
+ {
74
+ "id": _slug(name),
75
+ "label": name,
76
+ "caption": "",
77
+ "color": _SAMPLE_COLORS[index % len(_SAMPLE_COLORS)],
78
+ }
79
+ for index, name in enumerate(names)
80
+ ]
81
+
82
+
83
+ def _key_result(data: ReportData) -> dict:
84
+ metrics: dict[str, dict] = {}
85
+ for sample in data.quality_by_sample:
86
+ key = _slug(sample.metric or "score")
87
+ bucket = metrics.setdefault(key, {"name": sample.metric or "score", "bySample": {}})
88
+ bucket["bySample"][_slug(sample.evaluation_scope)] = {
89
+ "baseline": sample.baseline,
90
+ "baselineCi": sample.baseline_std,
91
+ "enriched": sample.enriched,
92
+ "enrichedCi": sample.enriched_std,
93
+ }
94
+ default_metric = next(iter(metrics), "")
95
+ has_std = any(
96
+ sample.baseline_std is not None or sample.enriched_std is not None for sample in data.quality_by_sample
97
+ )
98
+ return {
99
+ "title": "Key result",
100
+ "headline": "",
101
+ "defaultMetric": default_metric,
102
+ "confidenceNote": "± values are cross-validation standard deviation" if has_std else "",
103
+ "columns": {
104
+ "sample": "Sample",
105
+ "baseline": "Baseline",
106
+ "enriched": "Enriched",
107
+ "upliftAbs": "Uplift, abs",
108
+ "upliftPct": "Uplift, %",
109
+ },
110
+ "metrics": metrics,
111
+ }
112
+
113
+
114
+ def _summary_cards(summary: SearchResultsSummary) -> list[dict]:
115
+ stability = None
116
+ if summary.stable_features_share is not None:
117
+ stability = f"{summary.stable_features_share * 100:.0f}%"
118
+ return [
119
+ {"label": "Features found", "value": _dash(summary.relevant_features), "caption": ""},
120
+ {"label": "Used in model", "value": _dash(summary.model_features), "caption": ""},
121
+ {"label": "Data sources", "value": _dash(summary.data_sources), "caption": ""},
122
+ {"label": "Stability", "value": _dash(stability), "caption": ""},
123
+ ]
124
+
125
+
126
+ def _sample_stats_payload(data: ReportData, samples: list[dict]) -> dict:
127
+ by_id = {_slug(stats.sample): stats for stats in data.sample_stats}
128
+ rows = [
129
+ _stat_row("Date range", samples, by_id, lambda stats: stats.date_range),
130
+ _stat_row("Count", samples, by_id, lambda stats: _format_int(stats.rows)),
131
+ _stat_row("Count (target)", samples, by_id, lambda stats: _format_int(stats.labeled)),
132
+ ]
133
+ if data.is_binary:
134
+ rows.append(_stat_row("Count 1's (target)", samples, by_id, lambda stats: _format_int(stats.positive)))
135
+ rows.extend(
136
+ [
137
+ _stat_row("Mean (target)", samples, by_id, lambda stats: _format_mean(stats.mean_target)),
138
+ _stat_row("Count (unlabeled)", samples, by_id, lambda stats: _format_int(stats.unlabeled)),
139
+ ]
140
+ )
141
+ return {"rows": rows, "monthlyAxis": [], "monthlyPoints": []}
142
+
143
+
144
+ def _stat_row(label: str, samples: list[dict], by_id: dict[str, SampleStats], value) -> dict:
145
+ values = {}
146
+ for sample in samples:
147
+ stats = by_id.get(sample["id"]) or SampleStats(sample=sample["label"])
148
+ values[sample["id"]] = _dash(value(stats))
149
+ return {"label": label, "values": values}
150
+
151
+
152
+ def _slug(name: str) -> str:
153
+ return name.strip().lower().replace(" ", "_")
154
+
155
+
156
+ def _dash(value: Optional[object]) -> str:
157
+ if value is None or value == "":
158
+ return "—"
159
+ return str(value)
160
+
161
+
162
+ def _format_int(value: Optional[int]) -> Optional[str]:
163
+ return f"{value:,}" if value is not None else None
164
+
165
+
166
+ def _format_mean(value: Optional[float]) -> Optional[str]:
167
+ return f"{value:.3f}" if value is not None else None