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.
- {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Core/ODPS_Tool.py +52 -29
- supermodelingfactory-0.3.1/Modeling_Tool/Core/Parallel_Engine.py +450 -0
- supermodelingfactory-0.3.1/Modeling_Tool/Core/Parallel_ODPS_Manager.py +351 -0
- {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Core/__init__.py +13 -0
- supermodelingfactory-0.3.1/Modeling_Tool/Pipeline/__init__.py +55 -0
- supermodelingfactory-0.3.1/Modeling_Tool/Pipeline/_common.py +132 -0
- supermodelingfactory-0.3.1/Modeling_Tool/Pipeline/credit_model.py +952 -0
- supermodelingfactory-0.3.1/Modeling_Tool/Pipeline/feature_validation.py +927 -0
- supermodelingfactory-0.3.1/Modeling_Tool/Pipeline/mock_sample.py +259 -0
- supermodelingfactory-0.3.1/Modeling_Tool/Pipeline/reject_inference.py +522 -0
- supermodelingfactory-0.3.1/Modeling_Tool/Pipeline/sample_analysis.py +555 -0
- supermodelingfactory-0.3.1/Modeling_Tool/Pipeline/score_comparison.py +295 -0
- supermodelingfactory-0.3.1/Modeling_Tool/Pipeline/score_consistency_uat.py +230 -0
- {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/__init__.py +48 -1
- {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/PKG-INFO +2 -2
- {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/README.md +1 -1
- {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/SuperModelingFactory.egg-info/PKG-INFO +2 -2
- {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/SuperModelingFactory.egg-info/SOURCES.txt +11 -0
- {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/pyproject.toml +1 -1
- {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/setup.py +1 -1
- {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/ExcelMaster/ExcelFormatTool.py +0 -0
- {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/ExcelMaster/ExcelMaster.py +0 -0
- {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/ExcelMaster/Template.py +0 -0
- {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/ExcelMaster/Utility.py +0 -0
- {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/ExcelMaster/__init__.py +0 -0
- {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/LICENSE +0 -0
- {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/MANIFEST.in +0 -0
- {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Core/Binning_Tool.py +0 -0
- {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Core/Binning_Tool.pyi +0 -0
- {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Core/Check_DuckDB_Compatibility.py +0 -0
- {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Core/Json_Data_Converter.py +0 -0
- {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Core/Model_Registry_Tool.py +0 -0
- {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Core/Slope_Tool.py +0 -0
- {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Core/Slope_Tool.pyi +0 -0
- {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Core/XOR_Encryptor.py +0 -0
- {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Core/XOR_Encryptor.pyi +0 -0
- {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Core/kDataFrame.py +0 -0
- {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Core/kDataFrame.pyi +0 -0
- {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Core/sample_weight_utils.py +0 -0
- {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Core/utils.py +0 -0
- {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Eval/Evaluation_Tool.py +0 -0
- {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Eval/Evaluation_Tool.pyi +0 -0
- {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Eval/Model_Eval_Tool.py +0 -0
- {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Eval/Model_Eval_Tool.pyi +0 -0
- {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Eval/__init__.py +0 -0
- {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Eval/evaluate_model.py +0 -0
- {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Eval/evaluate_model.pyi +0 -0
- {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Eval/weighted_eval_utils.py +0 -0
- {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Explainability/Coalition_Structure.py +0 -0
- {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Explainability/Model_Explainer.py +0 -0
- {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Explainability/__init__.py +0 -0
- {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Feature/Distribution_Tool.py +0 -0
- {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Feature/Distribution_Tool.pyi +0 -0
- {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Feature/Feature_Insights.py +0 -0
- {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Feature/Feature_Insights.pyi +0 -0
- {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Feature/PSI_Tool.py +0 -0
- {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Feature/PSI_Tool.pyi +0 -0
- {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Feature/WOE_Engine_Feature_Patch.py +0 -0
- {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Feature/__init__.py +0 -0
- {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Model/Backward_Tool.py +0 -0
- {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Model/Backward_Tool.pyi +0 -0
- {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Model/GBM_Search_Tool.py +0 -0
- {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Model/GBM_Tool.py +0 -0
- {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Model/GBM_Tool.pyi +0 -0
- {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Model/LRM_Tool.py +0 -0
- {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Model/LRM_Tool.pyi +0 -0
- {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Model/__init__.py +0 -0
- {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Sample/Distribution_Adaptation.py +0 -0
- {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Sample/Distribution_Adaptation.pyi +0 -0
- {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Sample/Reject_Infer.py +0 -0
- {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Sample/Reject_Infer.pyi +0 -0
- {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Sample/Sample_Split.py +0 -0
- {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Sample/Sample_Split.pyi +0 -0
- {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/Sample/__init__.py +0 -0
- {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/UAT/UAT_Consistency_Checker.py +0 -0
- {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/UAT/__init__.py +0 -0
- {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/WOE/WOE_Adapter.py +0 -0
- {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/WOE/WOE_Adapter.pyi +0 -0
- {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/WOE/WOE_Master.py +0 -0
- {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/WOE/WOE_Master.pyi +0 -0
- {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/WOE/WOE_Monotone_Binner.py +0 -0
- {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/WOE/WOE_Monotone_Binner.pyi +0 -0
- {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/WOE/WOE_Plot_Tool.py +0 -0
- {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/WOE/WOE_Plot_Tool.pyi +0 -0
- {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/WOE/WOE_Report_Builder.py +0 -0
- {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/WOE/WOE_Report_Builder.pyi +0 -0
- {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/WOE/WOE_Tool.py +0 -0
- {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/WOE/WOE_Tool.pyi +0 -0
- {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/WOE/__init__.py +0 -0
- {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/WOE/plot_woe_tool.py +0 -0
- {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Modeling_Tool/WOE/plot_woe_tool.pyi +0 -0
- {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Report/Report_Tool.py +0 -0
- {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/Report/__init__.py +0 -0
- {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/SuperModelingFactory.egg-info/dependency_links.txt +0 -0
- {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/SuperModelingFactory.egg-info/not-zip-safe +0 -0
- {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/SuperModelingFactory.egg-info/requires.txt +0 -0
- {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/SuperModelingFactory.egg-info/top_level.txt +0 -0
- {supermodelingfactory-0.2.1 → supermodelingfactory-0.3.1}/requirements.txt +0 -0
- {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 时,
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
@
|
|
174
|
-
|
|
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.
|
|
179
|
-
_build_input_stream to omit the columns param
|
|
180
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|