upgini 1.2.167a4220.dev3__tar.gz → 1.2.167a4220.dev5__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.dev5}/PKG-INFO +1 -1
  2. upgini-1.2.167a4220.dev5/src/upgini/__about__.py +1 -0
  3. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev5}/src/upgini/features_enricher.py +126 -2
  4. upgini-1.2.167a4220.dev5/src/upgini/report/__init__.py +11 -0
  5. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev5}/src/upgini/report/assemble.py +23 -3
  6. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev5}/src/upgini/report/data.py +65 -0
  7. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev5}/src/upgini/report/html.py +106 -11
  8. upgini-1.2.167a4220.dev5/src/upgini/report/stats.py +433 -0
  9. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev5}/src/upgini/report/template.html +13 -8
  10. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev5}/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.dev5}/.gitignore +0 -0
  14. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev5}/LICENSE +0 -0
  15. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev5}/README.md +0 -0
  16. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev5}/pyproject.toml +0 -0
  17. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev5}/src/upgini/__init__.py +0 -0
  18. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev5}/src/upgini/ads.py +0 -0
  19. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev5}/src/upgini/ads_management/__init__.py +0 -0
  20. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev5}/src/upgini/ads_management/ads_manager.py +0 -0
  21. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev5}/src/upgini/autofe/__init__.py +0 -0
  22. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev5}/src/upgini/autofe/all_operators.py +0 -0
  23. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev5}/src/upgini/autofe/binary.py +0 -0
  24. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev5}/src/upgini/autofe/date.py +0 -0
  25. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev5}/src/upgini/autofe/feature.py +0 -0
  26. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev5}/src/upgini/autofe/groupby.py +0 -0
  27. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev5}/src/upgini/autofe/operand.py +0 -0
  28. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev5}/src/upgini/autofe/operator.py +0 -0
  29. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev5}/src/upgini/autofe/timeseries/__init__.py +0 -0
  30. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev5}/src/upgini/autofe/timeseries/base.py +0 -0
  31. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev5}/src/upgini/autofe/timeseries/cross.py +0 -0
  32. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev5}/src/upgini/autofe/timeseries/delta.py +0 -0
  33. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev5}/src/upgini/autofe/timeseries/lag.py +0 -0
  34. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev5}/src/upgini/autofe/timeseries/numpy_kernels.py +0 -0
  35. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev5}/src/upgini/autofe/timeseries/roll.py +0 -0
  36. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev5}/src/upgini/autofe/timeseries/trend.py +0 -0
  37. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev5}/src/upgini/autofe/timeseries/volatility.py +0 -0
  38. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev5}/src/upgini/autofe/unary.py +0 -0
  39. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev5}/src/upgini/autofe/utils.py +0 -0
  40. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev5}/src/upgini/autofe/vector.py +0 -0
  41. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev5}/src/upgini/data_source/__init__.py +0 -0
  42. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev5}/src/upgini/data_source/data_source_publisher.py +0 -0
  43. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev5}/src/upgini/dataset.py +0 -0
  44. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev5}/src/upgini/errors.py +0 -0
  45. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev5}/src/upgini/http.py +0 -0
  46. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev5}/src/upgini/mdc/__init__.py +0 -0
  47. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev5}/src/upgini/mdc/context.py +0 -0
  48. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev5}/src/upgini/metadata.py +0 -0
  49. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev5}/src/upgini/metrics.py +0 -0
  50. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev5}/src/upgini/normalizer/__init__.py +0 -0
  51. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev5}/src/upgini/normalizer/normalize_utils.py +0 -0
  52. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev5}/src/upgini/resource_bundle/__init__.py +0 -0
  53. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev5}/src/upgini/resource_bundle/exceptions.py +0 -0
  54. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev5}/src/upgini/resource_bundle/strings.properties +0 -0
  55. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev5}/src/upgini/resource_bundle/strings_widget.properties +0 -0
  56. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev5}/src/upgini/sampler/__init__.py +0 -0
  57. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev5}/src/upgini/sampler/base.py +0 -0
  58. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev5}/src/upgini/sampler/random_under_sampler.py +0 -0
  59. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev5}/src/upgini/sampler/utils.py +0 -0
  60. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev5}/src/upgini/search_task.py +0 -0
  61. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev5}/src/upgini/spinner.py +0 -0
  62. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev5}/src/upgini/utils/Roboto-Regular.ttf +0 -0
  63. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev5}/src/upgini/utils/__init__.py +0 -0
  64. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev5}/src/upgini/utils/base_search_key_detector.py +0 -0
  65. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev5}/src/upgini/utils/blocked_time_series.py +0 -0
  66. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev5}/src/upgini/utils/config.py +0 -0
  67. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev5}/src/upgini/utils/country_utils.py +0 -0
  68. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev5}/src/upgini/utils/cpu_utils.py +0 -0
  69. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev5}/src/upgini/utils/custom_loss_utils.py +0 -0
  70. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev5}/src/upgini/utils/cv_utils.py +0 -0
  71. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev5}/src/upgini/utils/datetime_utils.py +0 -0
  72. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev5}/src/upgini/utils/deduplicate_utils.py +0 -0
  73. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev5}/src/upgini/utils/display_utils.py +0 -0
  74. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev5}/src/upgini/utils/email_utils.py +0 -0
  75. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev5}/src/upgini/utils/fallback_progress_bar.py +0 -0
  76. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev5}/src/upgini/utils/feature_info.py +0 -0
  77. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev5}/src/upgini/utils/features_validator.py +0 -0
  78. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev5}/src/upgini/utils/format.py +0 -0
  79. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev5}/src/upgini/utils/hash_utils.py +0 -0
  80. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev5}/src/upgini/utils/ip_utils.py +0 -0
  81. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev5}/src/upgini/utils/mstats.py +0 -0
  82. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev5}/src/upgini/utils/one_hot_encoder.py +0 -0
  83. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev5}/src/upgini/utils/phone_utils.py +0 -0
  84. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev5}/src/upgini/utils/postal_code_utils.py +0 -0
  85. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev5}/src/upgini/utils/progress_bar.py +0 -0
  86. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev5}/src/upgini/utils/pyarrow_utils.py +0 -0
  87. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev5}/src/upgini/utils/sample_utils.py +0 -0
  88. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev5}/src/upgini/utils/search_key_derivations.py +0 -0
  89. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev5}/src/upgini/utils/sklearn_ext.py +0 -0
  90. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev5}/src/upgini/utils/sort.py +0 -0
  91. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev5}/src/upgini/utils/target_utils.py +0 -0
  92. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev5}/src/upgini/utils/track_info.py +0 -0
  93. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev5}/src/upgini/utils/ts_utils.py +0 -0
  94. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev5}/src/upgini/utils/warning_counter.py +0 -0
  95. {upgini-1.2.167a4220.dev3 → upgini-1.2.167a4220.dev5}/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.dev5
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.dev5"
@@ -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,
@@ -1461,6 +1462,9 @@ class FeaturesEnricher(TransformerMixin):
1461
1462
  progress_bar: bool = True,
1462
1463
  progress_callback: Callable | None = None,
1463
1464
  ):
1465
+ if self._has_single_ensemble_score():
1466
+ self.logger.info("Skip OOT PSI stability check for single ensemble score")
1467
+ return
1464
1468
  search_keys = self.search_keys.copy()
1465
1469
  search_keys.update(self._get_autodetected_search_keys())
1466
1470
  validated_X, _, validated_eval_set = self._validate_train_eval(X, y, eval_set)
@@ -2308,17 +2312,135 @@ class FeaturesEnricher(TransformerMixin):
2308
2312
  rows += _num_samples(eval_pair[0])
2309
2313
  return rows
2310
2314
 
2315
+ def _search_key_names(self) -> list[str]:
2316
+ return [key.name for key in self.search_keys.values()]
2317
+
2311
2318
  def _report_sample_names(self) -> list[str]:
2312
2319
  samples = [self.bundle.get("quality_metrics_train_segment")]
2313
2320
  for idx, _ in enumerate(self.eval_set or []):
2314
2321
  samples.append(self.bundle.get("quality_metrics_eval_segment").format(idx + 1))
2315
2322
  return samples
2316
2323
 
2324
+ def _report_dataset_samples(self) -> dict[str, pd.DataFrame]:
2325
+ names = self._report_sample_names()
2326
+ if self.df_with_original_index is not None:
2327
+ return self._report_samples_from_combined(self.df_with_original_index, names, self.fit_search_keys)
2328
+ if self.X is None or self.y is None:
2329
+ return {}
2330
+ date_col = self._get_date_column(self.search_keys)
2331
+ frames = {names[0]: self._report_xy_frame(self.X, self.y, date_col)}
2332
+ for idx, (eval_x, eval_y) in enumerate(self.eval_set or []):
2333
+ if idx + 1 >= len(names):
2334
+ break
2335
+ frames[names[idx + 1]] = self._report_xy_frame(eval_x, eval_y, date_col)
2336
+ return frames
2337
+
2338
+ def _report_samples_from_combined(
2339
+ self, df: pd.DataFrame, names: list[str], search_keys: dict[str, SearchKey] | None
2340
+ ) -> dict[str, pd.DataFrame]:
2341
+ date_col = self._get_date_column(search_keys or {})
2342
+ if EVAL_SET_INDEX not in df.columns:
2343
+ return {names[0]: self._report_df_frame(df, date_col)} if names else {}
2344
+ frames: dict[str, pd.DataFrame] = {}
2345
+ for eval_idx in sorted(pd.unique(df[EVAL_SET_INDEX].dropna())):
2346
+ name_idx = int(eval_idx)
2347
+ if name_idx >= len(names):
2348
+ continue
2349
+ frames[names[name_idx]] = self._report_df_frame(df[df[EVAL_SET_INDEX] == eval_idx], date_col)
2350
+ return frames
2351
+
2352
+ def _report_xy_frame(self, X, y, date_col: str | None) -> pd.DataFrame:
2353
+ date = X[date_col] if isinstance(X, pd.DataFrame) and date_col and date_col in X.columns else None
2354
+ return make_report_frame(y, date=date)
2355
+
2356
+ def _report_df_frame(self, df: pd.DataFrame, date_col: str | None) -> pd.DataFrame:
2357
+ target = df[TARGET] if TARGET in df.columns else pd.Series(np.nan, index=df.index)
2358
+ return self._report_xy_frame(df, target, date_col)
2359
+
2360
+ def _report_scored_samples(self) -> dict[str, pd.DataFrame]:
2361
+ cached = self._peek_cached_sampled()
2362
+ if cached is None:
2363
+ return {}
2364
+ X_sampled, y_sampled, enriched_X, eval_set_sampled_dict, search_keys, columns_renaming, _generated = cached
2365
+ score_col = self._report_score_column(enriched_X)
2366
+ if score_col is None:
2367
+ return {}
2368
+ date_col = self._get_date_column(search_keys or {})
2369
+ names = self._report_sample_names()
2370
+ frames = {
2371
+ names[0]: self._report_scored_frame(
2372
+ X_sampled, y_sampled, enriched_X, score_col, date_col, columns_renaming
2373
+ )
2374
+ }
2375
+ for idx, (eval_X, enriched_eval_X, eval_y) in eval_set_sampled_dict.items():
2376
+ name_idx = idx + 1
2377
+ if name_idx >= len(names):
2378
+ continue
2379
+ frames[names[name_idx]] = self._report_scored_frame(
2380
+ eval_X, eval_y, enriched_eval_X, score_col, date_col, columns_renaming
2381
+ )
2382
+ return frames
2383
+
2384
+ def _report_scored_frame(
2385
+ self,
2386
+ X: pd.DataFrame,
2387
+ y,
2388
+ enriched: pd.DataFrame,
2389
+ score_col: str,
2390
+ date_col: str | None,
2391
+ columns_renaming: dict[str, str] | None,
2392
+ ) -> pd.DataFrame:
2393
+ target = y
2394
+ if target is None and TARGET in enriched.columns:
2395
+ target = enriched[TARGET]
2396
+ date_name = self._report_existing_column(date_col, columns_renaming, X, enriched)
2397
+ baseline_name = self._report_existing_column(self.baseline_score_column, columns_renaming, X, enriched)
2398
+ return make_report_frame(
2399
+ target,
2400
+ date=self._report_column_values(date_name, X, enriched),
2401
+ score=enriched[score_col] if score_col in enriched.columns else None,
2402
+ baseline=self._report_column_values(baseline_name, X, enriched),
2403
+ )
2404
+
2405
+ def _report_score_column(self, enriched_X: pd.DataFrame) -> str | None:
2406
+ name = self._get_single_ensemble_score_name()
2407
+ if name and name in enriched_X.columns:
2408
+ return name
2409
+ return None
2410
+
2411
+ def _report_existing_column(
2412
+ self, name: str | None, renaming: dict[str, str] | None, *frames: pd.DataFrame
2413
+ ) -> str | None:
2414
+ if not name:
2415
+ return None
2416
+ aliases = self._column_name_aliases([name], renaming or {})
2417
+ for frame in frames:
2418
+ if not isinstance(frame, pd.DataFrame):
2419
+ continue
2420
+ for col in frame.columns:
2421
+ if col in aliases:
2422
+ return col
2423
+ return None
2424
+
2425
+ @staticmethod
2426
+ def _report_column_values(column: str | None, *frames: pd.DataFrame):
2427
+ if not column:
2428
+ return None
2429
+ for frame in frames:
2430
+ if isinstance(frame, pd.DataFrame) and column in frame.columns:
2431
+ return frame[column]
2432
+ return None
2433
+
2434
+ def _peek_cached_sampled(self):
2435
+ if not self.__cached_sampled_datasets:
2436
+ return None
2437
+ return max(self.__cached_sampled_datasets.values(), key=lambda item: len(item[0]) if item[0] is not None else 0)
2438
+
2317
2439
  def _assemble_report_data(self):
2318
2440
  search_id = self._search_task.search_task_id if self._search_task is not None else (self.search_id or "")
2319
2441
  return assemble_report_data(
2320
2442
  search_id=search_id,
2321
- search_keys=[str(sk) for sk in self.search_keys.values()],
2443
+ search_keys=self._search_key_names(),
2322
2444
  search_duration_seconds=self.search_duration_seconds,
2323
2445
  reference_rows=self._reference_rows(),
2324
2446
  samples=self._report_sample_names(),
@@ -2326,6 +2448,8 @@ class FeaturesEnricher(TransformerMixin):
2326
2448
  metric_name=self.metrics_metric_name,
2327
2449
  model_features=self._ensemble_model_feature_count(),
2328
2450
  is_binary=self.model_task_type == ModelTaskType.BINARY,
2451
+ dataset_samples=self._report_dataset_samples(),
2452
+ scored_samples=self._report_scored_samples(),
2329
2453
  bundle=self.bundle,
2330
2454
  )
2331
2455
 
@@ -6092,7 +6216,7 @@ if response.status_code == 200:
6092
6216
  autofe_descriptions_df=self.get_autofe_features_description(),
6093
6217
  search_id=self._search_task.search_task_id,
6094
6218
  email=self.rest_client.get_current_email(),
6095
- search_keys=[str(sk) for sk in self.search_keys.values()],
6219
+ search_keys=self._search_key_names(),
6096
6220
  eval_sets_drift_df=eval_sets_drift_df,
6097
6221
  display_id=display_id,
6098
6222
  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},
@@ -73,23 +79,43 @@ def _samples(data: ReportData) -> list[dict]:
73
79
  {
74
80
  "id": _slug(name),
75
81
  "label": name,
76
- "caption": "",
82
+ "caption": "OOF validation" if index == 0 else "",
77
83
  "color": _SAMPLE_COLORS[index % len(_SAMPLE_COLORS)],
78
84
  }
79
85
  for index, name in enumerate(names)
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: