SuperModelingFactory 0.2.1__tar.gz → 0.3.1__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 (99) hide show
  1. {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Core/ODPS_Tool.py +52 -29
  2. supermodelingfactory-0.3.1/Modeling_Tool/Core/Parallel_Engine.py +450 -0
  3. supermodelingfactory-0.3.1/Modeling_Tool/Core/Parallel_ODPS_Manager.py +351 -0
  4. {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Core/__init__.py +13 -0
  5. supermodelingfactory-0.3.1/Modeling_Tool/Pipeline/__init__.py +55 -0
  6. supermodelingfactory-0.3.1/Modeling_Tool/Pipeline/_common.py +132 -0
  7. supermodelingfactory-0.3.1/Modeling_Tool/Pipeline/credit_model.py +952 -0
  8. supermodelingfactory-0.3.1/Modeling_Tool/Pipeline/feature_validation.py +927 -0
  9. supermodelingfactory-0.3.1/Modeling_Tool/Pipeline/mock_sample.py +259 -0
  10. supermodelingfactory-0.3.1/Modeling_Tool/Pipeline/reject_inference.py +522 -0
  11. supermodelingfactory-0.3.1/Modeling_Tool/Pipeline/sample_analysis.py +555 -0
  12. supermodelingfactory-0.3.1/Modeling_Tool/Pipeline/score_comparison.py +295 -0
  13. supermodelingfactory-0.3.1/Modeling_Tool/Pipeline/score_consistency_uat.py +230 -0
  14. {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/__init__.py +48 -1
  15. {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/PKG-INFO +2 -2
  16. {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/README.md +1 -1
  17. {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/SuperModelingFactory.egg-info/PKG-INFO +2 -2
  18. {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/SuperModelingFactory.egg-info/SOURCES.txt +11 -0
  19. {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/pyproject.toml +1 -1
  20. {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/setup.py +1 -1
  21. {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/ExcelMaster/ExcelFormatTool.py +0 -0
  22. {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/ExcelMaster/ExcelMaster.py +0 -0
  23. {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/ExcelMaster/Template.py +0 -0
  24. {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/ExcelMaster/Utility.py +0 -0
  25. {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/ExcelMaster/__init__.py +0 -0
  26. {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/LICENSE +0 -0
  27. {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/MANIFEST.in +0 -0
  28. {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Core/Binning_Tool.py +0 -0
  29. {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Core/Binning_Tool.pyi +0 -0
  30. {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Core/Check_DuckDB_Compatibility.py +0 -0
  31. {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Core/Json_Data_Converter.py +0 -0
  32. {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Core/Model_Registry_Tool.py +0 -0
  33. {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Core/Slope_Tool.py +0 -0
  34. {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Core/Slope_Tool.pyi +0 -0
  35. {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Core/XOR_Encryptor.py +0 -0
  36. {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Core/XOR_Encryptor.pyi +0 -0
  37. {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Core/kDataFrame.py +0 -0
  38. {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Core/kDataFrame.pyi +0 -0
  39. {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Core/sample_weight_utils.py +0 -0
  40. {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Core/utils.py +0 -0
  41. {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Eval/Evaluation_Tool.py +0 -0
  42. {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Eval/Evaluation_Tool.pyi +0 -0
  43. {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Eval/Model_Eval_Tool.py +0 -0
  44. {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Eval/Model_Eval_Tool.pyi +0 -0
  45. {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Eval/__init__.py +0 -0
  46. {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Eval/evaluate_model.py +0 -0
  47. {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Eval/evaluate_model.pyi +0 -0
  48. {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Eval/weighted_eval_utils.py +0 -0
  49. {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Explainability/Coalition_Structure.py +0 -0
  50. {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Explainability/Model_Explainer.py +0 -0
  51. {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Explainability/__init__.py +0 -0
  52. {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Feature/Distribution_Tool.py +0 -0
  53. {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Feature/Distribution_Tool.pyi +0 -0
  54. {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Feature/Feature_Insights.py +0 -0
  55. {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Feature/Feature_Insights.pyi +0 -0
  56. {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Feature/PSI_Tool.py +0 -0
  57. {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Feature/PSI_Tool.pyi +0 -0
  58. {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Feature/WOE_Engine_Feature_Patch.py +0 -0
  59. {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Feature/__init__.py +0 -0
  60. {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Model/Backward_Tool.py +0 -0
  61. {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Model/Backward_Tool.pyi +0 -0
  62. {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Model/GBM_Search_Tool.py +0 -0
  63. {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Model/GBM_Tool.py +0 -0
  64. {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Model/GBM_Tool.pyi +0 -0
  65. {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Model/LRM_Tool.py +0 -0
  66. {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Model/LRM_Tool.pyi +0 -0
  67. {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Model/__init__.py +0 -0
  68. {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Sample/Distribution_Adaptation.py +0 -0
  69. {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Sample/Distribution_Adaptation.pyi +0 -0
  70. {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Sample/Reject_Infer.py +0 -0
  71. {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Sample/Reject_Infer.pyi +0 -0
  72. {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Sample/Sample_Split.py +0 -0
  73. {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Sample/Sample_Split.pyi +0 -0
  74. {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Sample/__init__.py +0 -0
  75. {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/UAT/UAT_Consistency_Checker.py +0 -0
  76. {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/UAT/__init__.py +0 -0
  77. {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/WOE/WOE_Adapter.py +0 -0
  78. {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/WOE/WOE_Adapter.pyi +0 -0
  79. {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/WOE/WOE_Master.py +0 -0
  80. {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/WOE/WOE_Master.pyi +0 -0
  81. {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/WOE/WOE_Monotone_Binner.py +0 -0
  82. {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/WOE/WOE_Monotone_Binner.pyi +0 -0
  83. {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/WOE/WOE_Plot_Tool.py +0 -0
  84. {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/WOE/WOE_Plot_Tool.pyi +0 -0
  85. {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/WOE/WOE_Report_Builder.py +0 -0
  86. {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/WOE/WOE_Report_Builder.pyi +0 -0
  87. {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/WOE/WOE_Tool.py +0 -0
  88. {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/WOE/WOE_Tool.pyi +0 -0
  89. {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/WOE/__init__.py +0 -0
  90. {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/WOE/plot_woe_tool.py +0 -0
  91. {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/WOE/plot_woe_tool.pyi +0 -0
  92. {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Report/Report_Tool.py +0 -0
  93. {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Report/__init__.py +0 -0
  94. {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/SuperModelingFactory.egg-info/dependency_links.txt +0 -0
  95. {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/SuperModelingFactory.egg-info/not-zip-safe +0 -0
  96. {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/SuperModelingFactory.egg-info/requires.txt +0 -0
  97. {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/SuperModelingFactory.egg-info/top_level.txt +0 -0
  98. {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/requirements.txt +0 -0
  99. {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/setup.cfg +0 -0
@@ -1,6 +1,8 @@
1
1
  from datetime import datetime
2
+ from contextlib import contextmanager
2
3
  import logging
3
4
  import os
5
+ import threading
4
6
  logger = logging.getLogger(__name__)
5
7
  import pandas as pd
6
8
  from odps import ODPS, options
@@ -16,6 +18,11 @@ pd.options.mode.chained_assignment = None # default='warn'
16
18
  logging.basicConfig(level=logging.INFO, format="%(message)s")
17
19
 
18
20
  class ODPSRunner(object):
21
+ _wide_schema_patch_lock = threading.RLock()
22
+ _wide_schema_patch_ref_count = 0
23
+ _wide_schema_orig_build = None
24
+ _wide_schema_patch_active = False
25
+
19
26
  """ODPS执行类
20
27
  """
21
28
  def __init__(self):
@@ -64,7 +71,7 @@ class ODPSRunner(object):
64
71
  -----
65
72
  - **执行**阶段(execute_sql)只跑一次, 无重试.
66
73
  - **下载**阶段(to_pandas + to_csv)最多重试 6 次, 适用于网络抖动.
67
- - 当 SQL 返回列数 > 200 时, ``_patch_wide_schema_download`` 自动 patch ODPS Tunnel,
74
+ - 当 SQL 返回列数 > 200 时, 线程安全的 wide-schema patch 会自动 patch ODPS Tunnel,
68
75
  防止 HTTP 414 (URI too long).
69
76
 
70
77
  Examples
@@ -92,18 +99,17 @@ class ODPSRunner(object):
92
99
  if should_download:
93
100
  k = 6
94
101
  for i in range(k):
95
- orig_build = None
96
102
  try:
97
103
  logging.info(f' to_pandas: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}')
98
104
  reader = executor.open_reader()
99
- orig_build = self._patch_wide_schema_download(reader)
100
- if n_process > 1:
101
- df = reader.to_pandas(n_process=n_process)
102
- else:
103
- df = reader.to_pandas()
105
+ with self._wide_schema_download_patch(reader):
106
+ if n_process > 1:
107
+ df = reader.to_pandas(n_process=n_process)
108
+ else:
109
+ df = reader.to_pandas()
104
110
  if bool(csv_path):
105
111
  logging.info(f' to_csv: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}')
106
- df.to_csv(csv_path)
112
+ df.to_csv(csv_path, index=False)
107
113
  break
108
114
  except Exception as e:
109
115
  logging.error(f' download failed [{i+1}/{k}]: {e}')
@@ -113,10 +119,6 @@ class ODPSRunner(object):
113
119
  raise SystemError(
114
120
  f' break: {endtime.strftime("%Y-%m-%d %H:%M:%S")} duration {duration}\n'
115
121
  )
116
- finally:
117
- if orig_build is not None:
118
- from odps.tunnel.instancetunnel import InstanceDownloadSession
119
- InstanceDownloadSession._build_input_stream = orig_build
120
122
 
121
123
  # 当用户显式要求不要 DataFrame 时, 主动释放引用, 节省内存
122
124
  if not to_df:
@@ -170,34 +172,55 @@ class ODPSRunner(object):
170
172
 
171
173
  return df
172
174
 
173
- @staticmethod
174
- def _patch_wide_schema_download(reader, col_threshold=200):
175
+ @classmethod
176
+ @contextmanager
177
+ def _wide_schema_download_patch(cls, reader, col_threshold=200):
175
178
  """Prevent HTTP 414 when a query result has many columns.
176
179
 
177
180
  ODPS Tunnel encodes column names as URL query params. With 200+ columns
178
- the URI exceeds the server limit. We temporarily replace the class-level
179
- _build_input_stream to omit the columns param, so the server returns all
180
- columns without URL-based filtering.
181
-
182
- Returns the original method so the caller can restore it in a finally block.
183
- Returns None when no patch is needed (schema is narrow enough).
181
+ the URI exceeds the server limit. This context temporarily replaces the
182
+ class-level _build_input_stream to omit the columns param. The patch is
183
+ guarded by a lock and reference count, so concurrent threads do not
184
+ restore the global method while another wide-schema download is active.
184
185
  """
185
186
  ds = getattr(reader, '_download_session', None)
186
187
  if ds is None:
187
- return None
188
+ yield False
189
+ return
188
190
  schema = getattr(ds, 'schema', None)
189
191
  if schema is None or len(schema.simple_columns) <= col_threshold:
190
- return None
192
+ yield False
193
+ return
191
194
 
192
195
  from odps.tunnel.instancetunnel import InstanceDownloadSession
193
- orig_build = InstanceDownloadSession._build_input_stream
194
-
195
- def _build_no_column_filter(self, start, count, compress=False, columns=None, arrow=False, raw_size=None):
196
- return orig_build(self, start, count, compress=compress, columns=None, arrow=arrow, raw_size=raw_size)
197
196
 
198
- InstanceDownloadSession._build_input_stream = _build_no_column_filter
199
- logging.info(f' wide schema ({len(schema.simple_columns)} cols): patched tunnel to omit column filter from URL')
200
- return orig_build
197
+ with cls._wide_schema_patch_lock:
198
+ if not cls._wide_schema_patch_active:
199
+ cls._wide_schema_orig_build = InstanceDownloadSession._build_input_stream
200
+
201
+ def _build_no_column_filter(self, start, count, compress=False, columns=None, arrow=False, raw_size=None):
202
+ with cls._wide_schema_patch_lock:
203
+ orig_build = cls._wide_schema_orig_build
204
+ if orig_build is None:
205
+ raise RuntimeError("ODPS wide-schema download patch lost its original method.")
206
+ return orig_build(self, start, count, compress=compress, columns=None, arrow=arrow, raw_size=raw_size)
207
+
208
+ InstanceDownloadSession._build_input_stream = _build_no_column_filter
209
+ cls._wide_schema_patch_active = True
210
+ logging.info(f' wide schema ({len(schema.simple_columns)} cols): patched tunnel to omit column filter from URL')
211
+ cls._wide_schema_patch_ref_count += 1
212
+
213
+ try:
214
+ yield True
215
+ finally:
216
+ with cls._wide_schema_patch_lock:
217
+ cls._wide_schema_patch_ref_count -= 1
218
+ if cls._wide_schema_patch_ref_count <= 0:
219
+ if cls._wide_schema_orig_build is not None:
220
+ InstanceDownloadSession._build_input_stream = cls._wide_schema_orig_build
221
+ cls._wide_schema_orig_build = None
222
+ cls._wide_schema_patch_ref_count = 0
223
+ cls._wide_schema_patch_active = False
201
224
 
202
225
  @staticmethod
203
226
  def cre_table_schema(df, partition_name=None):
@@ -0,0 +1,450 @@
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ import multiprocessing
5
+ import time
6
+ import traceback
7
+ from dataclasses import dataclass, field
8
+ from typing import Any, Callable, Literal, Sequence
9
+
10
+ import numpy as np
11
+ import pandas as pd
12
+
13
+
14
+ SplitAxis = Literal["row", "column", "chunk", "auto"]
15
+ Backend = Literal["process", "thread", "sequential"]
16
+ CombineMode = Literal["concat", "list", "dict", "none"]
17
+ ErrorMode = Literal["raise", "collect"]
18
+
19
+
20
+ @dataclass
21
+ class ParallelApplyConfig:
22
+ split_axis: SplitAxis = "row"
23
+ backend: Backend = "process"
24
+ n_jobs: int | str | None = "auto"
25
+ chunk_size: int | None = None
26
+ n_chunks: int | None = None
27
+ preserve_order: bool = True
28
+ combine: CombineMode = "concat"
29
+ concat_axis: Literal[0, 1] | None = None
30
+ pass_chunk_info: bool = False
31
+ timeout: float | None = None
32
+ on_error: ErrorMode = "raise"
33
+ validate_picklable: bool = True
34
+ auto_probe: bool = False
35
+ random_state: int | None = None
36
+ required_cols: list[str] = field(default_factory=list)
37
+ id_cols: list[str] = field(default_factory=list)
38
+
39
+
40
+ @dataclass
41
+ class ParallelApplyResult:
42
+ output: Any
43
+ chunk_outputs: list[Any] = field(default_factory=list)
44
+ errors: pd.DataFrame = field(default_factory=pd.DataFrame)
45
+ summary: dict[str, Any] = field(default_factory=dict)
46
+ config: ParallelApplyConfig | None = None
47
+ split_axis_resolved: str | None = None
48
+
49
+
50
+ @dataclass
51
+ class _ChunkSpec:
52
+ chunk_id: int
53
+ chunk: Any
54
+ rows: int | None = None
55
+ columns: list[str] | None = None
56
+
57
+
58
+ def _run_chunk(
59
+ func: Callable[..., Any],
60
+ chunk: Any,
61
+ func_args: tuple[Any, ...],
62
+ func_kwargs: dict[str, Any],
63
+ pass_chunk_info: bool,
64
+ chunk_info: dict[str, Any],
65
+ collect_errors: bool,
66
+ ) -> dict[str, Any]:
67
+ try:
68
+ kwargs = dict(func_kwargs)
69
+ if pass_chunk_info:
70
+ kwargs["chunk_info"] = chunk_info
71
+ return {
72
+ "ok": True,
73
+ "chunk_id": chunk_info["chunk_id"],
74
+ "output": func(chunk, *func_args, **kwargs),
75
+ "error": None,
76
+ }
77
+ except Exception as exc:
78
+ if not collect_errors:
79
+ raise
80
+ return {
81
+ "ok": False,
82
+ "chunk_id": chunk_info["chunk_id"],
83
+ "output": None,
84
+ "error": {
85
+ "chunk_id": chunk_info["chunk_id"],
86
+ "error_type": type(exc).__name__,
87
+ "error_message": str(exc),
88
+ "traceback": traceback.format_exc(),
89
+ },
90
+ }
91
+
92
+
93
+ class ParallelApplyEngine:
94
+ """Parallel function dispatcher for DataFrame and custom chunk workloads."""
95
+
96
+ _VALID_SPLIT_AXIS = {"row", "column", "chunk", "auto"}
97
+ _VALID_BACKENDS = {"process", "thread", "sequential"}
98
+ _VALID_COMBINE = {"concat", "list", "dict", "none"}
99
+ _VALID_ON_ERROR = {"raise", "collect"}
100
+
101
+ def __init__(self, config: ParallelApplyConfig | None = None):
102
+ self.config = config or ParallelApplyConfig()
103
+ self._validate_config(self.config)
104
+
105
+ def run(
106
+ self,
107
+ data: Any = None,
108
+ func: Callable[..., Any] | None = None,
109
+ func_args: Sequence[Any] = (),
110
+ func_kwargs: dict[str, Any] | None = None,
111
+ chunks: Sequence[Any] | None = None,
112
+ ) -> ParallelApplyResult:
113
+ if func is None:
114
+ raise ValueError("func is required.")
115
+
116
+ cfg = self.config
117
+ func_kwargs = {} if func_kwargs is None else dict(func_kwargs)
118
+ func_args = tuple(func_args)
119
+ self._validate_runtime_inputs(data=data, chunks=chunks)
120
+ if cfg.validate_picklable and cfg.backend == "process":
121
+ self._validate_picklable(func, func_args, func_kwargs)
122
+
123
+ start = time.time()
124
+ split_axis = self._resolve_split_axis(data, func, func_args, func_kwargs, chunks)
125
+ chunk_specs = self._make_chunks(data=data, chunks=chunks, split_axis=split_axis)
126
+ n_jobs = self._resolve_n_jobs(cfg.n_jobs, len(chunk_specs))
127
+
128
+ raw_results = self._execute(
129
+ func=func,
130
+ func_args=func_args,
131
+ func_kwargs=func_kwargs,
132
+ chunk_specs=chunk_specs,
133
+ n_jobs=n_jobs,
134
+ )
135
+ if cfg.preserve_order:
136
+ raw_results = sorted(raw_results, key=lambda item: item["chunk_id"])
137
+
138
+ successful = [item for item in raw_results if item["ok"]]
139
+ chunk_outputs = [item["output"] for item in successful]
140
+ errors = pd.DataFrame([item["error"] for item in raw_results if not item["ok"]])
141
+ output = self._combine_outputs(successful, split_axis)
142
+ elapsed = time.time() - start
143
+
144
+ return ParallelApplyResult(
145
+ output=output,
146
+ chunk_outputs=chunk_outputs,
147
+ errors=errors,
148
+ summary={
149
+ "split_axis": split_axis,
150
+ "backend": cfg.backend,
151
+ "n_jobs": n_jobs,
152
+ "n_chunks": len(chunk_specs),
153
+ "n_success": len(successful),
154
+ "n_error": int(len(errors)),
155
+ "elapsed_seconds": elapsed,
156
+ },
157
+ config=cfg,
158
+ split_axis_resolved=split_axis,
159
+ )
160
+
161
+ def _validate_config(self, cfg: ParallelApplyConfig) -> None:
162
+ if cfg.split_axis not in self._VALID_SPLIT_AXIS:
163
+ raise ValueError(f"split_axis must be one of {sorted(self._VALID_SPLIT_AXIS)}")
164
+ if cfg.backend not in self._VALID_BACKENDS:
165
+ raise ValueError(f"backend must be one of {sorted(self._VALID_BACKENDS)}")
166
+ if cfg.combine not in self._VALID_COMBINE:
167
+ raise ValueError(f"combine must be one of {sorted(self._VALID_COMBINE)}")
168
+ if cfg.on_error not in self._VALID_ON_ERROR:
169
+ raise ValueError(f"on_error must be one of {sorted(self._VALID_ON_ERROR)}")
170
+ if cfg.chunk_size is not None and cfg.chunk_size <= 0:
171
+ raise ValueError("chunk_size must be a positive integer.")
172
+ if cfg.n_chunks is not None and cfg.n_chunks <= 0:
173
+ raise ValueError("n_chunks must be a positive integer.")
174
+ if cfg.chunk_size is not None and cfg.n_chunks is not None:
175
+ raise ValueError("chunk_size and n_chunks cannot be used together.")
176
+ if cfg.concat_axis is not None and cfg.concat_axis not in {0, 1}:
177
+ raise ValueError("concat_axis must be 0, 1, or None.")
178
+
179
+ def _validate_runtime_inputs(self, data: Any, chunks: Sequence[Any] | None) -> None:
180
+ cfg = self.config
181
+ if cfg.split_axis in {"row", "column", "auto"} and not isinstance(data, pd.DataFrame):
182
+ raise TypeError("data must be a pandas DataFrame when split_axis is row, column, or auto.")
183
+ if cfg.split_axis == "chunk" and chunks is None:
184
+ raise ValueError("chunks is required when split_axis='chunk'.")
185
+
186
+ def _validate_picklable(
187
+ self,
188
+ func: Callable[..., Any],
189
+ func_args: tuple[Any, ...],
190
+ func_kwargs: dict[str, Any],
191
+ ) -> None:
192
+ try:
193
+ from joblib.externals import cloudpickle
194
+
195
+ cloudpickle.dumps((func, func_args, func_kwargs))
196
+ except Exception as exc:
197
+ raise TypeError(
198
+ "func, func_args, and func_kwargs must be serializable for backend='process'. "
199
+ "Use backend='thread' or backend='sequential' for non-serializable callables."
200
+ ) from exc
201
+
202
+ def _resolve_split_axis(
203
+ self,
204
+ data: Any,
205
+ func: Callable[..., Any],
206
+ func_args: tuple[Any, ...],
207
+ func_kwargs: dict[str, Any],
208
+ chunks: Sequence[Any] | None,
209
+ ) -> str:
210
+ cfg = self.config
211
+ if cfg.split_axis != "auto":
212
+ return cfg.split_axis
213
+ if chunks is not None:
214
+ return "chunk"
215
+ return self._probe_split_axis(data, func, func_args, func_kwargs)
216
+
217
+ def _probe_split_axis(
218
+ self,
219
+ data: pd.DataFrame,
220
+ func: Callable[..., Any],
221
+ func_args: tuple[Any, ...],
222
+ func_kwargs: dict[str, Any],
223
+ ) -> str:
224
+ sample = data.sample(
225
+ n=min(len(data), 20),
226
+ random_state=self.config.random_state,
227
+ ) if len(data) > 20 else data.copy()
228
+ full_output = func(sample.copy(), *func_args, **func_kwargs)
229
+ candidates: list[str] = []
230
+ for axis in ("row", "column"):
231
+ try:
232
+ chunk_specs = self._make_chunks(data=sample, chunks=None, split_axis=axis, force_n_chunks=2)
233
+ outputs = []
234
+ for spec in chunk_specs:
235
+ outputs.append(func(spec.chunk, *func_args, **func_kwargs))
236
+ probe_results = [
237
+ {"chunk_id": i, "output": output}
238
+ for i, output in enumerate(outputs)
239
+ ]
240
+ combined = self._combine_outputs(probe_results, axis)
241
+ if self._outputs_equivalent(full_output, combined):
242
+ candidates.append(axis)
243
+ except Exception:
244
+ continue
245
+ if len(candidates) == 1:
246
+ return candidates[0]
247
+ if len(candidates) > 1:
248
+ raise ValueError(
249
+ "split_axis='auto' is ambiguous: both row and column probes matched. "
250
+ "Please set split_axis explicitly."
251
+ )
252
+ raise ValueError(
253
+ "split_axis='auto' could not prove that the function is row- or column-splittable. "
254
+ "Please set split_axis explicitly."
255
+ )
256
+
257
+ def _outputs_equivalent(self, left: Any, right: Any) -> bool:
258
+ try:
259
+ if isinstance(left, pd.DataFrame) and isinstance(right, pd.DataFrame):
260
+ pd.testing.assert_frame_equal(left, right, check_dtype=False, check_exact=False)
261
+ return True
262
+ if isinstance(left, pd.Series) and isinstance(right, pd.Series):
263
+ pd.testing.assert_series_equal(left, right, check_dtype=False, check_exact=False)
264
+ return True
265
+ if isinstance(left, np.ndarray) and isinstance(right, np.ndarray):
266
+ return bool(np.allclose(left, right, equal_nan=True))
267
+ return left == right
268
+ except Exception:
269
+ return False
270
+
271
+ def _make_chunks(
272
+ self,
273
+ data: Any,
274
+ chunks: Sequence[Any] | None,
275
+ split_axis: str,
276
+ force_n_chunks: int | None = None,
277
+ ) -> list[_ChunkSpec]:
278
+ if split_axis == "chunk":
279
+ return [
280
+ _ChunkSpec(chunk_id=i, chunk=chunk, rows=self._safe_len(chunk))
281
+ for i, chunk in enumerate(chunks or [])
282
+ ]
283
+ if split_axis == "row":
284
+ return self._make_row_chunks(data, force_n_chunks)
285
+ if split_axis == "column":
286
+ return self._make_column_chunks(data, force_n_chunks)
287
+ raise ValueError(f"Unsupported split_axis: {split_axis}")
288
+
289
+ def _make_row_chunks(self, data: pd.DataFrame, force_n_chunks: int | None = None) -> list[_ChunkSpec]:
290
+ indices = self._split_positions(len(data), force_n_chunks)
291
+ return [
292
+ _ChunkSpec(chunk_id=i, chunk=data.iloc[pos].copy(), rows=len(pos), columns=list(data.columns))
293
+ for i, pos in enumerate(indices)
294
+ if len(pos) > 0
295
+ ]
296
+
297
+ def _make_column_chunks(self, data: pd.DataFrame, force_n_chunks: int | None = None) -> list[_ChunkSpec]:
298
+ fixed_cols = self._dedupe(self.config.id_cols + self.config.required_cols)
299
+ missing = [col for col in fixed_cols if col not in data.columns]
300
+ if missing:
301
+ raise KeyError(f"required/id columns not found in data: {missing}")
302
+ split_cols = [col for col in data.columns if col not in set(fixed_cols)]
303
+ positions = self._split_positions(len(split_cols), force_n_chunks)
304
+ specs = []
305
+ for i, pos in enumerate(positions):
306
+ cols = fixed_cols + [split_cols[j] for j in pos]
307
+ if len(cols) == 0:
308
+ continue
309
+ specs.append(
310
+ _ChunkSpec(
311
+ chunk_id=i,
312
+ chunk=data.loc[:, cols].copy(),
313
+ rows=len(data),
314
+ columns=cols,
315
+ )
316
+ )
317
+ return specs
318
+
319
+ def _split_positions(self, n_items: int, force_n_chunks: int | None = None) -> list[np.ndarray]:
320
+ if n_items < 0:
321
+ raise ValueError("n_items must be non-negative.")
322
+ if n_items == 0:
323
+ return [np.array([], dtype=int)]
324
+ if force_n_chunks is not None:
325
+ n_chunks = min(max(1, force_n_chunks), n_items)
326
+ elif self.config.chunk_size is not None:
327
+ n_chunks = int(math.ceil(n_items / self.config.chunk_size))
328
+ elif self.config.n_chunks is not None:
329
+ n_chunks = min(self.config.n_chunks, n_items)
330
+ else:
331
+ n_chunks = min(self._resolve_n_jobs(self.config.n_jobs, n_items), n_items)
332
+ return [arr for arr in np.array_split(np.arange(n_items), n_chunks) if len(arr) > 0]
333
+
334
+ def _resolve_n_jobs(self, n_jobs: int | str | None, n_tasks: int) -> int:
335
+ if n_tasks <= 0:
336
+ return 1
337
+ if n_jobs is None:
338
+ resolved = 1
339
+ elif isinstance(n_jobs, str):
340
+ if n_jobs != "auto":
341
+ raise ValueError("n_jobs as string only supports 'auto'.")
342
+ resolved = max(1, multiprocessing.cpu_count() - 1)
343
+ elif n_jobs == -1:
344
+ resolved = multiprocessing.cpu_count()
345
+ elif n_jobs <= 0:
346
+ raise ValueError("n_jobs must be a positive integer, -1, 'auto', or None.")
347
+ else:
348
+ resolved = int(n_jobs)
349
+ return min(max(1, resolved), max(1, n_tasks))
350
+
351
+ def _execute(
352
+ self,
353
+ func: Callable[..., Any],
354
+ func_args: tuple[Any, ...],
355
+ func_kwargs: dict[str, Any],
356
+ chunk_specs: list[_ChunkSpec],
357
+ n_jobs: int,
358
+ ) -> list[dict[str, Any]]:
359
+ cfg = self.config
360
+ collect_errors = cfg.on_error == "collect"
361
+ if cfg.backend == "sequential" or n_jobs == 1:
362
+ return [
363
+ _run_chunk(
364
+ func,
365
+ spec.chunk,
366
+ func_args,
367
+ func_kwargs,
368
+ cfg.pass_chunk_info,
369
+ self._chunk_info(spec),
370
+ collect_errors,
371
+ )
372
+ for spec in chunk_specs
373
+ ]
374
+
375
+ from joblib import Parallel, delayed
376
+
377
+ backend = "threading" if cfg.backend == "thread" else "loky"
378
+ parallel_kwargs = {"n_jobs": n_jobs, "backend": backend}
379
+ if cfg.timeout is not None:
380
+ parallel_kwargs["timeout"] = cfg.timeout
381
+ return Parallel(**parallel_kwargs)(
382
+ delayed(_run_chunk)(
383
+ func,
384
+ spec.chunk,
385
+ func_args,
386
+ func_kwargs,
387
+ cfg.pass_chunk_info,
388
+ self._chunk_info(spec),
389
+ collect_errors,
390
+ )
391
+ for spec in chunk_specs
392
+ )
393
+
394
+ def _combine_outputs(self, successful_results: list[dict[str, Any]], split_axis: str) -> Any:
395
+ cfg = self.config
396
+ outputs = [item["output"] for item in successful_results]
397
+ if cfg.combine == "none":
398
+ return None
399
+ if cfg.combine == "list":
400
+ return outputs
401
+ if cfg.combine == "dict":
402
+ return {item["chunk_id"]: item["output"] for item in successful_results}
403
+ if not outputs:
404
+ return None
405
+ concat_axis = cfg.concat_axis
406
+ if concat_axis is None:
407
+ concat_axis = 1 if split_axis == "column" else 0
408
+ return pd.concat(outputs, axis=concat_axis)
409
+
410
+ def _chunk_info(self, spec: _ChunkSpec) -> dict[str, Any]:
411
+ return {
412
+ "chunk_id": spec.chunk_id,
413
+ "rows": spec.rows,
414
+ "columns": spec.columns,
415
+ }
416
+
417
+ def _safe_len(self, obj: Any) -> int | None:
418
+ try:
419
+ return len(obj)
420
+ except Exception:
421
+ return None
422
+
423
+ def _dedupe(self, values: Sequence[str]) -> list[str]:
424
+ seen = set()
425
+ result = []
426
+ for value in values:
427
+ if value not in seen:
428
+ seen.add(value)
429
+ result.append(value)
430
+ return result
431
+
432
+
433
+ def parallel_apply(
434
+ data: Any = None,
435
+ func: Callable[..., Any] | None = None,
436
+ func_args: Sequence[Any] = (),
437
+ func_kwargs: dict[str, Any] | None = None,
438
+ chunks: Sequence[Any] | None = None,
439
+ **config_kwargs: Any,
440
+ ) -> Any:
441
+ """Run a function in parallel and return the combined output."""
442
+
443
+ config = ParallelApplyConfig(**config_kwargs)
444
+ return ParallelApplyEngine(config).run(
445
+ data=data,
446
+ func=func,
447
+ func_args=func_args,
448
+ func_kwargs=func_kwargs,
449
+ chunks=chunks,
450
+ ).output