upgini 1.2.167a4220.dev3__tar.gz → 1.2.167a4220.dev4__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.dev3 → upgini-1.2.167a4220.dev4}/PKG-INFO +1 -1
  2. upgini-1.2.167a4220.dev4/src/upgini/__about__.py +1 -0
  3. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev4}/src/upgini/features_enricher.py +123 -2
  4. upgini-1.2.167a4220.dev4/src/upgini/report/__init__.py +11 -0
  5. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev4}/src/upgini/report/assemble.py +23 -3
  6. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev4}/src/upgini/report/data.py +65 -0
  7. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev4}/src/upgini/report/html.py +105 -10
  8. upgini-1.2.167a4220.dev4/src/upgini/report/stats.py +426 -0
  9. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev4}/src/upgini/report/template.html +11 -6
  10. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev4}/src/upgini/utils/psi.py +20 -1
  11. upgini-1.2.167a4220.dev3/src/upgini/__about__.py +0 -1
  12. upgini-1.2.167a4220.dev3/src/upgini/report/__init__.py +0 -5
  13. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev4}/.gitignore +0 -0
  14. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev4}/LICENSE +0 -0
  15. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev4}/README.md +0 -0
  16. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev4}/pyproject.toml +0 -0
  17. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev4}/src/upgini/__init__.py +0 -0
  18. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev4}/src/upgini/ads.py +0 -0
  19. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev4}/src/upgini/ads_management/__init__.py +0 -0
  20. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev4}/src/upgini/ads_management/ads_manager.py +0 -0
  21. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev4}/src/upgini/autofe/__init__.py +0 -0
  22. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev4}/src/upgini/autofe/all_operators.py +0 -0
  23. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev4}/src/upgini/autofe/binary.py +0 -0
  24. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev4}/src/upgini/autofe/date.py +0 -0
  25. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev4}/src/upgini/autofe/feature.py +0 -0
  26. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev4}/src/upgini/autofe/groupby.py +0 -0
  27. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev4}/src/upgini/autofe/operand.py +0 -0
  28. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev4}/src/upgini/autofe/operator.py +0 -0
  29. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev4}/src/upgini/autofe/timeseries/__init__.py +0 -0
  30. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev4}/src/upgini/autofe/timeseries/base.py +0 -0
  31. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev4}/src/upgini/autofe/timeseries/cross.py +0 -0
  32. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev4}/src/upgini/autofe/timeseries/delta.py +0 -0
  33. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev4}/src/upgini/autofe/timeseries/lag.py +0 -0
  34. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev4}/src/upgini/autofe/timeseries/numpy_kernels.py +0 -0
  35. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev4}/src/upgini/autofe/timeseries/roll.py +0 -0
  36. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev4}/src/upgini/autofe/timeseries/trend.py +0 -0
  37. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev4}/src/upgini/autofe/timeseries/volatility.py +0 -0
  38. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev4}/src/upgini/autofe/unary.py +0 -0
  39. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev4}/src/upgini/autofe/utils.py +0 -0
  40. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev4}/src/upgini/autofe/vector.py +0 -0
  41. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev4}/src/upgini/data_source/__init__.py +0 -0
  42. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev4}/src/upgini/data_source/data_source_publisher.py +0 -0
  43. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev4}/src/upgini/dataset.py +0 -0
  44. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev4}/src/upgini/errors.py +0 -0
  45. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev4}/src/upgini/http.py +0 -0
  46. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev4}/src/upgini/mdc/__init__.py +0 -0
  47. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev4}/src/upgini/mdc/context.py +0 -0
  48. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev4}/src/upgini/metadata.py +0 -0
  49. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev4}/src/upgini/metrics.py +0 -0
  50. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev4}/src/upgini/normalizer/__init__.py +0 -0
  51. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev4}/src/upgini/normalizer/normalize_utils.py +0 -0
  52. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev4}/src/upgini/resource_bundle/__init__.py +0 -0
  53. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev4}/src/upgini/resource_bundle/exceptions.py +0 -0
  54. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev4}/src/upgini/resource_bundle/strings.properties +0 -0
  55. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev4}/src/upgini/resource_bundle/strings_widget.properties +0 -0
  56. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev4}/src/upgini/sampler/__init__.py +0 -0
  57. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev4}/src/upgini/sampler/base.py +0 -0
  58. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev4}/src/upgini/sampler/random_under_sampler.py +0 -0
  59. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev4}/src/upgini/sampler/utils.py +0 -0
  60. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev4}/src/upgini/search_task.py +0 -0
  61. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev4}/src/upgini/spinner.py +0 -0
  62. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev4}/src/upgini/utils/Roboto-Regular.ttf +0 -0
  63. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev4}/src/upgini/utils/__init__.py +0 -0
  64. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev4}/src/upgini/utils/base_search_key_detector.py +0 -0
  65. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev4}/src/upgini/utils/blocked_time_series.py +0 -0
  66. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev4}/src/upgini/utils/config.py +0 -0
  67. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev4}/src/upgini/utils/country_utils.py +0 -0
  68. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev4}/src/upgini/utils/cpu_utils.py +0 -0
  69. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev4}/src/upgini/utils/custom_loss_utils.py +0 -0
  70. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev4}/src/upgini/utils/cv_utils.py +0 -0
  71. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev4}/src/upgini/utils/datetime_utils.py +0 -0
  72. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev4}/src/upgini/utils/deduplicate_utils.py +0 -0
  73. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev4}/src/upgini/utils/display_utils.py +0 -0
  74. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev4}/src/upgini/utils/email_utils.py +0 -0
  75. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev4}/src/upgini/utils/fallback_progress_bar.py +0 -0
  76. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev4}/src/upgini/utils/feature_info.py +0 -0
  77. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev4}/src/upgini/utils/features_validator.py +0 -0
  78. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev4}/src/upgini/utils/format.py +0 -0
  79. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev4}/src/upgini/utils/hash_utils.py +0 -0
  80. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev4}/src/upgini/utils/ip_utils.py +0 -0
  81. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev4}/src/upgini/utils/mstats.py +0 -0
  82. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev4}/src/upgini/utils/one_hot_encoder.py +0 -0
  83. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev4}/src/upgini/utils/phone_utils.py +0 -0
  84. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev4}/src/upgini/utils/postal_code_utils.py +0 -0
  85. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev4}/src/upgini/utils/progress_bar.py +0 -0
  86. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev4}/src/upgini/utils/pyarrow_utils.py +0 -0
  87. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev4}/src/upgini/utils/sample_utils.py +0 -0
  88. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev4}/src/upgini/utils/search_key_derivations.py +0 -0
  89. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev4}/src/upgini/utils/sklearn_ext.py +0 -0
  90. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev4}/src/upgini/utils/sort.py +0 -0
  91. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev4}/src/upgini/utils/target_utils.py +0 -0
  92. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev4}/src/upgini/utils/track_info.py +0 -0
  93. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev4}/src/upgini/utils/ts_utils.py +0 -0
  94. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev4}/src/upgini/utils/warning_counter.py +0 -0
  95. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev4}/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.dev3
3
+ Version: 1.2.167a4220.dev4
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.dev4"
@@ -100,6 +100,7 @@ from upgini.utils.deduplicate_utils import (
100
100
  )
101
101
  from upgini.report.assemble import assemble_report_data
102
102
  from upgini.report.html import generate_html_report
103
+ from upgini.report.stats import make_report_frame
103
104
  from upgini.utils.display_utils import (
104
105
  display_html_dataframe,
105
106
  do_without_pandas_limits,
@@ -2308,17 +2309,135 @@ class FeaturesEnricher(TransformerMixin):
2308
2309
  rows += _num_samples(eval_pair[0])
2309
2310
  return rows
2310
2311
 
2312
+ def _search_key_names(self) -> list[str]:
2313
+ return [key.name for key in self.search_keys.values()]
2314
+
2311
2315
  def _report_sample_names(self) -> list[str]:
2312
2316
  samples = [self.bundle.get("quality_metrics_train_segment")]
2313
2317
  for idx, _ in enumerate(self.eval_set or []):
2314
2318
  samples.append(self.bundle.get("quality_metrics_eval_segment").format(idx + 1))
2315
2319
  return samples
2316
2320
 
2321
+ def _report_dataset_samples(self) -> dict[str, pd.DataFrame]:
2322
+ names = self._report_sample_names()
2323
+ if self.df_with_original_index is not None:
2324
+ return self._report_samples_from_combined(self.df_with_original_index, names, self.fit_search_keys)
2325
+ if self.X is None or self.y is None:
2326
+ return {}
2327
+ date_col = self._get_date_column(self.search_keys)
2328
+ frames = {names[0]: self._report_xy_frame(self.X, self.y, date_col)}
2329
+ for idx, (eval_x, eval_y) in enumerate(self.eval_set or []):
2330
+ if idx + 1 >= len(names):
2331
+ break
2332
+ frames[names[idx + 1]] = self._report_xy_frame(eval_x, eval_y, date_col)
2333
+ return frames
2334
+
2335
+ def _report_samples_from_combined(
2336
+ self, df: pd.DataFrame, names: list[str], search_keys: dict[str, SearchKey] | None
2337
+ ) -> dict[str, pd.DataFrame]:
2338
+ date_col = self._get_date_column(search_keys or {})
2339
+ if EVAL_SET_INDEX not in df.columns:
2340
+ return {names[0]: self._report_df_frame(df, date_col)} if names else {}
2341
+ frames: dict[str, pd.DataFrame] = {}
2342
+ for eval_idx in sorted(pd.unique(df[EVAL_SET_INDEX].dropna())):
2343
+ name_idx = int(eval_idx)
2344
+ if name_idx >= len(names):
2345
+ continue
2346
+ frames[names[name_idx]] = self._report_df_frame(df[df[EVAL_SET_INDEX] == eval_idx], date_col)
2347
+ return frames
2348
+
2349
+ def _report_xy_frame(self, X, y, date_col: str | None) -> pd.DataFrame:
2350
+ date = X[date_col] if isinstance(X, pd.DataFrame) and date_col and date_col in X.columns else None
2351
+ return make_report_frame(y, date=date)
2352
+
2353
+ def _report_df_frame(self, df: pd.DataFrame, date_col: str | None) -> pd.DataFrame:
2354
+ target = df[TARGET] if TARGET in df.columns else pd.Series(np.nan, index=df.index)
2355
+ return self._report_xy_frame(df, target, date_col)
2356
+
2357
+ def _report_scored_samples(self) -> dict[str, pd.DataFrame]:
2358
+ cached = self._peek_cached_sampled()
2359
+ if cached is None:
2360
+ return {}
2361
+ X_sampled, y_sampled, enriched_X, eval_set_sampled_dict, search_keys, columns_renaming, _generated = cached
2362
+ score_col = self._report_score_column(enriched_X)
2363
+ if score_col is None:
2364
+ return {}
2365
+ date_col = self._get_date_column(search_keys or {})
2366
+ names = self._report_sample_names()
2367
+ frames = {
2368
+ names[0]: self._report_scored_frame(
2369
+ X_sampled, y_sampled, enriched_X, score_col, date_col, columns_renaming
2370
+ )
2371
+ }
2372
+ for idx, (eval_X, enriched_eval_X, eval_y) in eval_set_sampled_dict.items():
2373
+ name_idx = idx + 1
2374
+ if name_idx >= len(names):
2375
+ continue
2376
+ frames[names[name_idx]] = self._report_scored_frame(
2377
+ eval_X, eval_y, enriched_eval_X, score_col, date_col, columns_renaming
2378
+ )
2379
+ return frames
2380
+
2381
+ def _report_scored_frame(
2382
+ self,
2383
+ X: pd.DataFrame,
2384
+ y,
2385
+ enriched: pd.DataFrame,
2386
+ score_col: str,
2387
+ date_col: str | None,
2388
+ columns_renaming: dict[str, str] | None,
2389
+ ) -> pd.DataFrame:
2390
+ target = y
2391
+ if target is None and TARGET in enriched.columns:
2392
+ target = enriched[TARGET]
2393
+ date_name = self._report_existing_column(date_col, columns_renaming, X, enriched)
2394
+ baseline_name = self._report_existing_column(self.baseline_score_column, columns_renaming, X, enriched)
2395
+ return make_report_frame(
2396
+ target,
2397
+ date=self._report_column_values(date_name, X, enriched),
2398
+ score=enriched[score_col] if score_col in enriched.columns else None,
2399
+ baseline=self._report_column_values(baseline_name, X, enriched),
2400
+ )
2401
+
2402
+ def _report_score_column(self, enriched_X: pd.DataFrame) -> str | None:
2403
+ name = self._get_single_ensemble_score_name()
2404
+ if name and name in enriched_X.columns:
2405
+ return name
2406
+ return None
2407
+
2408
+ def _report_existing_column(
2409
+ self, name: str | None, renaming: dict[str, str] | None, *frames: pd.DataFrame
2410
+ ) -> str | None:
2411
+ if not name:
2412
+ return None
2413
+ aliases = self._column_name_aliases([name], renaming or {})
2414
+ for frame in frames:
2415
+ if not isinstance(frame, pd.DataFrame):
2416
+ continue
2417
+ for col in frame.columns:
2418
+ if col in aliases:
2419
+ return col
2420
+ return None
2421
+
2422
+ @staticmethod
2423
+ def _report_column_values(column: str | None, *frames: pd.DataFrame):
2424
+ if not column:
2425
+ return None
2426
+ for frame in frames:
2427
+ if isinstance(frame, pd.DataFrame) and column in frame.columns:
2428
+ return frame[column]
2429
+ return None
2430
+
2431
+ def _peek_cached_sampled(self):
2432
+ if not self.__cached_sampled_datasets:
2433
+ return None
2434
+ return max(self.__cached_sampled_datasets.values(), key=lambda item: len(item[0]) if item[0] is not None else 0)
2435
+
2317
2436
  def _assemble_report_data(self):
2318
2437
  search_id = self._search_task.search_task_id if self._search_task is not None else (self.search_id or "")
2319
2438
  return assemble_report_data(
2320
2439
  search_id=search_id,
2321
- search_keys=[str(sk) for sk in self.search_keys.values()],
2440
+ search_keys=self._search_key_names(),
2322
2441
  search_duration_seconds=self.search_duration_seconds,
2323
2442
  reference_rows=self._reference_rows(),
2324
2443
  samples=self._report_sample_names(),
@@ -2326,6 +2445,8 @@ class FeaturesEnricher(TransformerMixin):
2326
2445
  metric_name=self.metrics_metric_name,
2327
2446
  model_features=self._ensemble_model_feature_count(),
2328
2447
  is_binary=self.model_task_type == ModelTaskType.BINARY,
2448
+ dataset_samples=self._report_dataset_samples(),
2449
+ scored_samples=self._report_scored_samples(),
2329
2450
  bundle=self.bundle,
2330
2451
  )
2331
2452
 
@@ -6092,7 +6213,7 @@ if response.status_code == 200:
6092
6213
  autofe_descriptions_df=self.get_autofe_features_description(),
6093
6214
  search_id=self._search_task.search_task_id,
6094
6215
  email=self.rest_client.get_current_email(),
6095
- search_keys=[str(sk) for sk in self.search_keys.values()],
6216
+ search_keys=self._search_key_names(),
6096
6217
  eval_sets_drift_df=eval_sets_drift_df,
6097
6218
  display_id=display_id,
6098
6219
  display_handle=display_handle,
@@ -0,0 +1,11 @@
1
+ from upgini.report.assemble import UPGINI_REPORT_BRANDING_URL, assemble_report_data, format_search_duration
2
+ from upgini.report.data import ReportData
3
+ from upgini.report.html import generate_html_report
4
+
5
+ __all__ = [
6
+ "ReportData",
7
+ "UPGINI_REPORT_BRANDING_URL",
8
+ "assemble_report_data",
9
+ "format_search_duration",
10
+ "generate_html_report",
11
+ ]
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import os
3
4
  from datetime import datetime, timezone
4
5
  from typing import Optional
5
6
 
@@ -7,8 +8,11 @@ import pandas as pd
7
8
 
8
9
  from upgini.__about__ import __version__
9
10
  from upgini.report.data import QualitySample, ReportData, ReportMetadata, SampleStats, SearchResultsSummary
11
+ from upgini.report.stats import compute_report_charts
10
12
  from upgini.resource_bundle import ResourceBundle
11
13
 
14
+ UPGINI_REPORT_BRANDING_URL = "UPGINI_REPORT_BRANDING_URL"
15
+
12
16
 
13
17
  def format_search_duration(seconds: Optional[float]) -> Optional[str]:
14
18
  if seconds is None:
@@ -36,11 +40,19 @@ def assemble_report_data(
36
40
  metric_name: Optional[str] = None,
37
41
  model_features: Optional[int] = None,
38
42
  is_binary: bool = False,
39
- logo_url: Optional[str] = None,
43
+ dataset_samples: Optional[dict[str, pd.DataFrame]] = None,
44
+ scored_samples: Optional[dict[str, pd.DataFrame]] = None,
40
45
  bundle: ResourceBundle,
41
46
  ) -> ReportData:
42
47
  generated_at = generated_at or datetime.now(timezone.utc)
43
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
+ )
44
56
  return ReportData(
45
57
  metadata=ReportMetadata(
46
58
  search_id=search_id,
@@ -50,15 +62,23 @@ def assemble_report_data(
50
62
  search_duration=format_search_duration(search_duration_seconds),
51
63
  reference_rows=reference_rows,
52
64
  samples=sample_names,
53
- logo_url=logo_url or None,
65
+ logo_url=_branding_url(),
54
66
  ),
55
67
  quality_by_sample=_quality_samples(metrics_df, metric_name, bundle),
56
68
  summary=SearchResultsSummary(model_features=model_features),
57
- sample_stats=_sample_stats(sample_names, metrics_df, bundle),
69
+ sample_stats=chart_stats or _sample_stats(sample_names, metrics_df, bundle),
58
70
  is_binary=is_binary,
71
+ charts=charts,
59
72
  )
60
73
 
61
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
+
62
82
  def _quality_samples(
63
83
  metrics_df: Optional[pd.DataFrame], metric_name: Optional[str], bundle: ResourceBundle
64
84
  ) -> list[QualitySample]:
@@ -79,6 +79,70 @@ class ModelFeatureShap:
79
79
  mean_abs_shap: Optional[float] = None
80
80
 
81
81
 
82
+ @dataclass
83
+ class MonthlySamplePoint:
84
+ month: str
85
+ sample: str
86
+ total: int
87
+ labeled: int
88
+ unlabeled: int
89
+ positive: int
90
+ mean: Optional[float] = None
91
+
92
+
93
+ @dataclass
94
+ class QualityMonthly:
95
+ evaluation_scope: str
96
+ metric: str
97
+ baseline: list[Optional[float]] = field(default_factory=list)
98
+ enriched: list[Optional[float]] = field(default_factory=list)
99
+
100
+
101
+ @dataclass
102
+ class ScoreMonthlyStats:
103
+ sample: str
104
+ mean_score: list[Optional[float]] = field(default_factory=list)
105
+ mean_target: list[Optional[float]] = field(default_factory=list)
106
+ labeled: list[Optional[int]] = field(default_factory=list)
107
+ unlabeled: list[Optional[int]] = field(default_factory=list)
108
+
109
+
110
+ @dataclass
111
+ class ScoreHistogram:
112
+ sample: str
113
+ target_0: list[float] = field(default_factory=list)
114
+ target_1: list[float] = field(default_factory=list)
115
+ n_target_0: int = 0
116
+ n_target_1: int = 0
117
+
118
+
119
+ @dataclass
120
+ class ScorePsi:
121
+ sample: str
122
+ psi: list[Optional[float]] = field(default_factory=list)
123
+ rows: list[Optional[int]] = field(default_factory=list)
124
+ coverage: list[Optional[float]] = field(default_factory=list)
125
+
126
+
127
+ @dataclass
128
+ class ReportCharts:
129
+ timeline_months: list[str] = field(default_factory=list)
130
+ period_options: list[int] = field(default_factory=list)
131
+ default_period: int = 0
132
+ sample_monthly: list[MonthlySamplePoint] = field(default_factory=list)
133
+ monthly_count_max: int = 0
134
+ quality_monthly: list[QualityMonthly] = field(default_factory=list)
135
+ score_monthly: list[ScoreMonthlyStats] = field(default_factory=list)
136
+ score_count_axis_max: int = 0
137
+ histogram_bin_edges: list[float] = field(default_factory=list)
138
+ histograms: list[ScoreHistogram] = field(default_factory=list)
139
+ histogram_density_max: float = 0.0
140
+ score_psi: list[ScorePsi] = field(default_factory=list)
141
+ psi_axis_max: float = 0.32
142
+ psi_warning: float = 0.1
143
+ psi_critical: float = 0.25
144
+
145
+
82
146
  @dataclass
83
147
  class ReportData:
84
148
  metadata: ReportMetadata
@@ -90,3 +154,4 @@ class ReportData:
90
154
  sources: list[SourceRow] = field(default_factory=list)
91
155
  autofe: list[dict[str, str]] = field(default_factory=list)
92
156
  model_feature_shap: list[ModelFeatureShap] = field(default_factory=list)
157
+ charts: ReportCharts = field(default_factory=ReportCharts)
@@ -4,7 +4,8 @@ import json
4
4
  from pathlib import Path
5
5
  from typing import Optional
6
6
 
7
- from upgini.report.data import ReportData, SampleStats, SearchResultsSummary
7
+ from upgini.report.data import ReportCharts, ReportData, SampleStats, SearchResultsSummary
8
+ from upgini.report.stats import MEAN_AXIS_PADDING
8
9
 
9
10
  _TEMPLATE_PATH = Path(__file__).with_name("template.html")
10
11
  _SAMPLE_COLORS = ("#1645ee", "#20aab4", "#53cc61", "#e19132", "#d45b5b")
@@ -21,16 +22,21 @@ def generate_html_report(data: ReportData) -> str:
21
22
 
22
23
  def _report_payload(data: ReportData) -> dict:
23
24
  samples = _samples(data)
25
+ charts = data.charts or ReportCharts()
24
26
  return {
25
27
  "meta": _meta(data, samples),
26
- "timeline": {"months": [], "periodOptions": [], "defaultPeriod": 0},
28
+ "timeline": {
29
+ "months": list(charts.timeline_months),
30
+ "periodOptions": list(charts.period_options),
31
+ "defaultPeriod": charts.default_period,
32
+ },
27
33
  "samples": samples,
28
- "keyResult": _key_result(data),
34
+ "keyResult": _key_result(data, charts),
29
35
  "summaryCards": _summary_cards(data.summary),
30
- "sampleStats": _sample_stats_payload(data, samples),
31
- "scoreAnalysis": {"bySample": {}},
32
- "scoreDistribution": {"bySample": {}},
33
- "scoreStability": {"bySample": {}},
36
+ "sampleStats": _sample_stats_payload(data, samples, charts),
37
+ "scoreAnalysis": _score_analysis_payload(charts, samples),
38
+ "scoreDistribution": _score_distribution_payload(charts, samples),
39
+ "scoreStability": _score_stability_payload(charts, samples),
34
40
  "features": [],
35
41
  "featureStability": {"bySample": {}},
36
42
  "shap": {"topN": 5},
@@ -80,16 +86,36 @@ def _samples(data: ReportData) -> list[dict]:
80
86
  ]
81
87
 
82
88
 
83
- def _key_result(data: ReportData) -> dict:
89
+ def _key_result(data: ReportData, charts: ReportCharts) -> dict:
90
+ series = {
91
+ (_slug(row.metric or "score"), _slug(row.evaluation_scope)): row for row in charts.quality_monthly
92
+ }
84
93
  metrics: dict[str, dict] = {}
85
94
  for sample in data.quality_by_sample:
86
95
  key = _slug(sample.metric or "score")
87
96
  bucket = metrics.setdefault(key, {"name": sample.metric or "score", "bySample": {}})
97
+ monthly = series.get((key, _slug(sample.evaluation_scope)))
88
98
  bucket["bySample"][_slug(sample.evaluation_scope)] = {
89
99
  "baseline": sample.baseline,
90
100
  "baselineCi": sample.baseline_std,
91
101
  "enriched": sample.enriched,
92
102
  "enrichedCi": sample.enriched_std,
103
+ "baselineSeries": list(monthly.baseline) if monthly else [],
104
+ "enrichedSeries": list(monthly.enriched) if monthly else [],
105
+ }
106
+ for monthly in charts.quality_monthly:
107
+ key = _slug(monthly.metric or "score")
108
+ sample_id = _slug(monthly.evaluation_scope)
109
+ bucket = metrics.setdefault(key, {"name": monthly.metric or "score", "bySample": {}})
110
+ if sample_id in bucket["bySample"]:
111
+ continue
112
+ bucket["bySample"][sample_id] = {
113
+ "baseline": None,
114
+ "baselineCi": None,
115
+ "enriched": None,
116
+ "enrichedCi": None,
117
+ "baselineSeries": list(monthly.baseline),
118
+ "enrichedSeries": list(monthly.enriched),
93
119
  }
94
120
  default_metric = next(iter(metrics), "")
95
121
  has_std = any(
@@ -123,7 +149,7 @@ def _summary_cards(summary: SearchResultsSummary) -> list[dict]:
123
149
  ]
124
150
 
125
151
 
126
- def _sample_stats_payload(data: ReportData, samples: list[dict]) -> dict:
152
+ def _sample_stats_payload(data: ReportData, samples: list[dict], charts: ReportCharts) -> dict:
127
153
  by_id = {_slug(stats.sample): stats for stats in data.sample_stats}
128
154
  rows = [
129
155
  _stat_row("Date range", samples, by_id, lambda stats: stats.date_range),
@@ -138,7 +164,76 @@ def _sample_stats_payload(data: ReportData, samples: list[dict]) -> dict:
138
164
  _stat_row("Count (unlabeled)", samples, by_id, lambda stats: _format_int(stats.unlabeled)),
139
165
  ]
140
166
  )
141
- return {"rows": rows, "monthlyAxis": [], "monthlyPoints": []}
167
+ return {
168
+ "rows": rows,
169
+ "monthlyAxis": list(charts.timeline_months),
170
+ "monthlyCountMax": charts.monthly_count_max or 1,
171
+ "monthlyMeanAxisPadding": MEAN_AXIS_PADDING,
172
+ "monthlyPoints": [
173
+ {
174
+ "month": point.month,
175
+ "sample": _slug(point.sample),
176
+ "total": point.total,
177
+ "labeled": point.labeled,
178
+ "unlabeled": point.unlabeled,
179
+ "positive": point.positive,
180
+ "mean": point.mean,
181
+ }
182
+ for point in charts.sample_monthly
183
+ ],
184
+ }
185
+
186
+
187
+ def _score_analysis_payload(charts: ReportCharts, samples: list[dict]) -> dict:
188
+ by_id = {_slug(row.sample): row for row in charts.score_monthly}
189
+ return {
190
+ "countAxisMax": charts.score_count_axis_max or 1,
191
+ "bySample": {
192
+ sample["id"]: {
193
+ "meanScore": list(row.mean_score),
194
+ "meanTarget": list(row.mean_target),
195
+ "labeled": list(row.labeled),
196
+ "unlabeled": list(row.unlabeled),
197
+ }
198
+ for sample in samples
199
+ if (row := by_id.get(sample["id"])) is not None
200
+ },
201
+ }
202
+
203
+
204
+ def _score_distribution_payload(charts: ReportCharts, samples: list[dict]) -> dict:
205
+ by_id = {_slug(row.sample): row for row in charts.histograms}
206
+ train = next((by_id.get(sample["id"]) for sample in samples if sample["id"] in by_id), None)
207
+ return {
208
+ "binEdges": list(charts.histogram_bin_edges),
209
+ "densityAxisMax": charts.histogram_density_max or 1,
210
+ "countScale": {
211
+ "target0": train.n_target_0 if train else 0,
212
+ "target1": train.n_target_1 if train else 0,
213
+ },
214
+ "bySample": {
215
+ sample["id"]: {"target0": list(row.target_0), "target1": list(row.target_1)}
216
+ for sample in samples
217
+ if (row := by_id.get(sample["id"])) is not None
218
+ },
219
+ }
220
+
221
+
222
+ def _score_stability_payload(charts: ReportCharts, samples: list[dict]) -> dict:
223
+ by_id = {_slug(row.sample): row for row in charts.score_psi}
224
+ return {
225
+ "psiAxisMax": charts.psi_axis_max or charts.psi_critical,
226
+ "thresholds": {"warning": charts.psi_warning, "critical": charts.psi_critical},
227
+ "bySample": {
228
+ sample["id"]: {
229
+ "psi": list(row.psi),
230
+ "rows": list(row.rows),
231
+ "scoreCoverage": list(row.coverage),
232
+ }
233
+ for sample in samples
234
+ if (row := by_id.get(sample["id"])) is not None
235
+ },
236
+ }
142
237
 
143
238
 
144
239
  def _stat_row(label: str, samples: list[dict], by_id: dict[str, SampleStats], value) -> dict: