424 lines
25 KiB
Python
424 lines
25 KiB
Python
#!/usr/bin/env python3
|
||
"""Controlled modeling tasks; workbook creation stays with apply_workbook.py."""
|
||
from __future__ import annotations
|
||
|
||
import math
|
||
from typing import Any
|
||
|
||
import numpy as np
|
||
import pandas as pd
|
||
|
||
from _xlsx_common import SkillArgumentParser, load_json_argument, run_cli
|
||
from _xlsx_data import SOURCE_ROW, numeric, read_dataset, require_columns, save_plan, scalar
|
||
|
||
|
||
def evaluate(frame: pd.DataFrame, spec: dict) -> tuple[list, dict]:
|
||
directions = spec["directions"]
|
||
columns = require_columns(frame, list(directions))
|
||
data = numeric(frame, columns).to_numpy()
|
||
if len(data) < 2:
|
||
raise ValueError("综合评价至少需要两个对象")
|
||
positive = np.zeros_like(data)
|
||
for j, name in enumerate(columns):
|
||
direction = directions[name]
|
||
col = data[:, j]
|
||
if direction == "benefit":
|
||
transformed = col - col.min()
|
||
elif direction == "cost":
|
||
transformed = col.max() - col
|
||
elif isinstance(direction, dict) and "target" in direction:
|
||
distance = np.abs(col - float(direction["target"]))
|
||
transformed = distance.max() - distance
|
||
else:
|
||
raise ValueError("指标方向需为 benefit、cost 或含 target 的对象")
|
||
if not np.isfinite(transformed).all():
|
||
raise ValueError("指标正向化结果无效")
|
||
positive[:, j] = transformed / transformed.max() if transformed.max() > 0 else 0
|
||
method = spec.get("weighting", "equal")
|
||
entropy = np.ones(len(columns))
|
||
consistency = None
|
||
if method == "equal":
|
||
weights = np.ones(len(columns))
|
||
elif method == "user":
|
||
if set(spec["weights"]) != set(columns):
|
||
raise ValueError("weights 必须覆盖且仅覆盖全部指标")
|
||
weights = np.array([spec["weights"][col] for col in columns], dtype=float)
|
||
elif method == "entropy":
|
||
sums = positive.sum(axis=0)
|
||
p = np.divide(positive, sums, out=np.zeros_like(positive), where=sums > 0)
|
||
logs = np.zeros_like(p)
|
||
np.log(p, out=logs, where=p > 0)
|
||
entropy = -(p * logs).sum(axis=0) / math.log(len(data))
|
||
entropy[sums == 0] = 1
|
||
weights = np.maximum(0, 1 - entropy)
|
||
if not weights.any():
|
||
weights = np.ones(len(columns))
|
||
elif method == "ahp":
|
||
matrix = np.asarray(spec["comparison_matrix"], dtype=float)
|
||
n = len(columns)
|
||
if not 2 <= n <= 9 or matrix.shape != (n, n) or not np.isfinite(matrix).all() or (matrix <= 0).any():
|
||
raise ValueError("AHP 需 2–9 阶正数比较矩阵,顺序与 directions 相同")
|
||
if not np.allclose(np.diag(matrix), 1) or not np.allclose(matrix * matrix.T, 1, atol=1e-6):
|
||
raise ValueError("AHP 比较矩阵必须对角为 1 且互反")
|
||
values, vectors = np.linalg.eig(matrix)
|
||
index = np.argmax(values.real)
|
||
weights = np.abs(vectors[:, index].real)
|
||
ri = [0, 0, 0, .58, .90, 1.12, 1.24, 1.32, 1.41, 1.45][n]
|
||
consistency = max(0, float(values[index].real - n) / (n - 1) / ri) if ri else 0
|
||
if consistency >= .1:
|
||
raise ValueError(f"AHP 一致性未通过:CR={consistency:.4f},需调整用户比较矩阵")
|
||
else:
|
||
raise ValueError("weighting 仅支持 equal、user、entropy、ahp")
|
||
if not np.isfinite(weights).all() or (weights < 0).any() or weights.sum() <= 0:
|
||
raise ValueError("权重必须非负、有限且总和大于 0")
|
||
weights /= weights.sum()
|
||
norms = np.linalg.norm(positive, axis=0)
|
||
weighted = np.divide(positive, norms, out=np.zeros_like(positive), where=norms > 0) * weights
|
||
# All columns are already benefit-oriented; reversing cost columns again is wrong.
|
||
d_best = np.linalg.norm(weighted - weighted.max(axis=0), axis=1)
|
||
d_worst = np.linalg.norm(weighted - weighted.min(axis=0), axis=1)
|
||
scores = np.divide(d_worst, d_best + d_worst, out=np.full(len(data), .5), where=d_best + d_worst > 0)
|
||
entity = require_columns(frame, [spec["entity"]])[0]
|
||
result = frame[[SOURCE_ROW, entity, *columns]].copy()
|
||
result["得分"] = scores
|
||
result["排名"] = result["得分"].round(12).rank(method="min", ascending=False).astype(int)
|
||
result = result.sort_values("排名", kind="stable")
|
||
weight_table = pd.DataFrame({"指标": columns, "方向": [str(directions[c]) for c in columns], "权重": weights,
|
||
"信息熵": entropy if method == "entropy" else [None] * len(columns),
|
||
"无区分度": ["是" if value == 0 else "否" for value in norms]})
|
||
return [("综合排名", result), ("指标权重", weight_table)], {"weighting": method, "ahp_cr": consistency,
|
||
"note": "正向化后统一采用最大值作为正理想解;无区分度对象可并列,熵权反映差异性而非业务重要性。"}
|
||
|
||
|
||
def _forecast_values(history: np.ndarray, horizon: int, method: str, season: int) -> np.ndarray:
|
||
if method == "naive":
|
||
return np.repeat(history[-1], horizon)
|
||
if method == "linear":
|
||
slope, intercept = np.polyfit(np.arange(len(history)), history, 1)
|
||
return intercept + slope * np.arange(len(history), len(history) + horizon)
|
||
if method == "moving_average":
|
||
values = list(history)
|
||
for _ in range(horizon):
|
||
values.append(float(np.mean(values[-min(3, len(values)):])))
|
||
return np.asarray(values[-horizon:])
|
||
if method == "seasonal_naive" and 1 <= season <= len(history):
|
||
return np.array([history[-season + (i % season)] for i in range(horizon)])
|
||
raise ValueError("预测方法无效,或 seasonal_period 超过训练数据长度")
|
||
|
||
|
||
def forecast(frame: pd.DataFrame, spec: dict) -> tuple[list, dict]:
|
||
value, date_col = require_columns(frame, [spec["value"], spec["date"]])
|
||
groups = spec.get("by", [])
|
||
if groups:
|
||
require_columns(frame, groups)
|
||
horizon = int(spec.get("horizon", 3))
|
||
if not 1 <= horizon <= 120:
|
||
raise ValueError("horizon 必须在 1–120 之间")
|
||
methods = spec.get("methods", ["naive", "linear"])
|
||
if not methods or set(methods) - {"naive", "linear", "moving_average", "seasonal_naive"}:
|
||
raise ValueError("不支持的预测方法")
|
||
freq = spec.get("frequency", "MS")
|
||
if freq not in {"D", "W", "MS", "QS", "YS"}:
|
||
raise ValueError("frequency 仅支持 D、W、MS、QS、YS")
|
||
frame = frame.copy()
|
||
frame[value] = numeric(frame, [value])[value]
|
||
frame[date_col] = pd.to_datetime(frame[date_col], errors="raise")
|
||
if frame[date_col].isna().any():
|
||
raise ValueError("预测日期列不能缺失")
|
||
predictions, validations = [], []
|
||
iterator = frame.groupby(groups, dropna=False, sort=False) if groups else [((), frame)]
|
||
for key, group in iterator:
|
||
group = group.sort_values(date_col)
|
||
keys = list(key if isinstance(key, tuple) else (key,)) if groups else []
|
||
dates = pd.DatetimeIndex(group[date_col])
|
||
expected = pd.date_range(dates[0], dates[-1], freq=freq)
|
||
if len(group) < 6 or dates.has_duplicates or not dates.equals(expected):
|
||
raise ValueError(f"分组 {keys} 需至少 6 期连续、无重复的 {freq} 数据;缺期不能自动当作 0")
|
||
holdout = int(spec.get("holdout", max(2, len(group) // 4)))
|
||
if not 1 <= holdout <= len(group) - 3:
|
||
raise ValueError("holdout 需保留至少 3 个训练时点")
|
||
values = group[value].to_numpy(dtype=float)
|
||
train, test = values[:-holdout], values[-holdout:]
|
||
season = int(spec.get("seasonal_period", 1))
|
||
candidates = []
|
||
for method in methods:
|
||
predicted = _forecast_values(train, holdout, method, season)
|
||
mae = float(np.mean(np.abs(predicted - test)))
|
||
rmse = float(np.sqrt(np.mean((predicted - test) ** 2)))
|
||
validations.append([*keys, method, len(train), holdout, mae, rmse])
|
||
candidates.append((mae, method))
|
||
selected = min(candidates, key=lambda item: item[0])[1]
|
||
future = _forecast_values(values, horizon, selected, season)
|
||
future_dates = pd.date_range(dates[-1], periods=horizon + 1, freq=freq)[1:]
|
||
predictions += [[*keys, date, float(prediction), selected] for date, prediction in zip(future_dates, future)]
|
||
return [("未来预测", pd.DataFrame(predictions, columns=[*groups, date_col, "预测值", "方法"])),
|
||
("时序验证", pd.DataFrame(validations, columns=[*groups, "方法", "训练期数", "验证期数", "MAE", "RMSE"])),
|
||
("历史数据", frame)], {"selection": "按时间留出验证集,以 MAE 选择方法;这不是独立测试集。",
|
||
"uncertainty": "基础趋势外推,无预测区间,未自动补期、截断负预测或假定季节性。"}
|
||
|
||
|
||
def supervised(frame: pd.DataFrame, spec: dict, *, classification: bool) -> tuple[list, dict]:
|
||
from sklearn.compose import ColumnTransformer
|
||
from sklearn.dummy import DummyClassifier, DummyRegressor
|
||
from sklearn.ensemble import RandomForestClassifier, RandomForestRegressor
|
||
from sklearn.impute import SimpleImputer
|
||
from sklearn.linear_model import LinearRegression, LogisticRegression, Ridge
|
||
from sklearn.metrics import accuracy_score, f1_score, mean_absolute_error, mean_squared_error, r2_score
|
||
from sklearn.model_selection import train_test_split
|
||
from sklearn.pipeline import Pipeline
|
||
from sklearn.preprocessing import OneHotEncoder, StandardScaler
|
||
|
||
features = require_columns(frame, spec["features"])
|
||
target = require_columns(frame, [spec["target"]])[0]
|
||
if target in features or SOURCE_ROW in features:
|
||
raise ValueError("目标列和原始行号不能作为特征")
|
||
categorical = spec.get("categorical", [])
|
||
if set(categorical) - set(features):
|
||
raise ValueError("categorical 必须是 features 的子集")
|
||
numerical = [col for col in features if col not in categorical]
|
||
X = frame[features].copy()
|
||
if numerical:
|
||
X[numerical] = numeric(frame, numerical, allow_missing=True)
|
||
for col in categorical:
|
||
X[col] = X[col].map(lambda x: str(x) if pd.notna(x) else np.nan)
|
||
if frame[target].isna().any() or len(frame) < 10:
|
||
raise ValueError("监督学习至少需要 10 行且目标列不能缺失")
|
||
y = frame[target].astype(str) if classification else numeric(frame, [target])[target]
|
||
fraction = float(spec.get("test_fraction", .2))
|
||
if not .1 <= fraction <= .5:
|
||
raise ValueError("test_fraction 必须在 0.1–0.5 之间")
|
||
test_rows = max(2, math.ceil(len(frame) * fraction))
|
||
indexes = np.arange(len(frame))
|
||
if spec.get("time_column"):
|
||
date_col = require_columns(frame, [spec["time_column"]])[0]
|
||
dates = pd.to_datetime(frame[date_col], errors="raise")
|
||
if dates.isna().any():
|
||
raise ValueError("时间列不能缺失")
|
||
indexes = np.argsort(dates.to_numpy(), kind="stable")
|
||
boundary = len(frame) - test_rows
|
||
train, test = indexes[:boundary], indexes[boundary:]
|
||
if dates.iloc[train].max() >= dates.iloc[test].min():
|
||
raise ValueError("时间切分边界有相同时点;请先按时点汇总或调整切分比例")
|
||
else:
|
||
train, test = train_test_split(indexes, test_size=test_rows, random_state=42, stratify=y if classification else None)
|
||
if classification and (y.iloc[train].nunique() < 2 or not set(y.iloc[test]) <= set(y.iloc[train])):
|
||
raise ValueError("训练集必须覆盖至少两个类别且包含测试集所有类别")
|
||
transformers = []
|
||
if numerical:
|
||
transformers.append(("numeric", Pipeline([("impute", SimpleImputer(strategy="median", keep_empty_features=True)), ("scale", StandardScaler())]), numerical))
|
||
if categorical:
|
||
transformers.append(("category", Pipeline([("impute", SimpleImputer(strategy="constant", fill_value="缺失", keep_empty_features=True)),
|
||
("encode", OneHotEncoder(handle_unknown="ignore", sparse_output=False, max_categories=50))]), categorical))
|
||
method = spec.get("algorithm", "logistic" if classification else "linear")
|
||
choices = ({"logistic": LogisticRegression(max_iter=1000, class_weight="balanced"),
|
||
"forest": RandomForestClassifier(n_estimators=100, max_depth=8, random_state=42, n_jobs=1, class_weight="balanced")}
|
||
if classification else {"linear": LinearRegression(), "ridge": Ridge(alpha=1.),
|
||
"forest": RandomForestRegressor(n_estimators=100, max_depth=8, random_state=42, n_jobs=1)})
|
||
if method not in choices:
|
||
raise ValueError(f"algorithm 可选:{list(choices)}")
|
||
pipeline = Pipeline([("prepare", ColumnTransformer(transformers)), ("model", choices[method])])
|
||
pipeline.fit(X.iloc[train], y.iloc[train])
|
||
baseline = DummyClassifier(strategy="most_frequent") if classification else DummyRegressor(strategy="mean")
|
||
baseline.fit(np.zeros((len(train), 1)), y.iloc[train])
|
||
metrics = []
|
||
for label, subset in (("训练", train), ("测试", test)):
|
||
actual = y.iloc[subset]
|
||
for algorithm, predicted in ((method, pipeline.predict(X.iloc[subset])), ("基线", baseline.predict(np.zeros((len(subset), 1))))):
|
||
stats = {"Accuracy": accuracy_score(actual, predicted), "F1_macro": f1_score(actual, predicted, average="macro", zero_division=0)} if classification else {
|
||
"MAE": mean_absolute_error(actual, predicted), "RMSE": math.sqrt(mean_squared_error(actual, predicted)), "R2": r2_score(actual, predicted)}
|
||
metrics += [[label, algorithm, key, float(value)] for key, value in stats.items()]
|
||
predictions = frame.iloc[test][[SOURCE_ROW, *features]].copy()
|
||
predictions["实际值"] = y.iloc[test].to_numpy()
|
||
predictions["预测值"] = pipeline.predict(X.iloc[test])
|
||
model = pipeline.named_steps["model"]
|
||
names = pipeline.named_steps["prepare"].get_feature_names_out()
|
||
importance = model.feature_importances_ if hasattr(model, "feature_importances_") else np.mean(np.abs(np.atleast_2d(model.coef_)), axis=0)
|
||
features_table = pd.DataFrame({"特征": names, "重要性或绝对系数": importance}).sort_values("重要性或绝对系数", ascending=False)
|
||
tables = [("留出预测", predictions), ("模型指标", pd.DataFrame(metrics, columns=["数据集", "模型", "指标", "值"])), ("特征说明", features_table)]
|
||
if spec.get("predict_source"):
|
||
request = spec["predict_source"]
|
||
future, provenance = read_dataset(request["path"], {k: v for k, v in request.items() if k != "path"})
|
||
require_columns(future, features)
|
||
future_X = future[features].copy()
|
||
if numerical:
|
||
future_X[numerical] = numeric(future, numerical, allow_missing=True)
|
||
for col in categorical:
|
||
future_X[col] = future_X[col].map(lambda x: str(x) if pd.notna(x) else np.nan)
|
||
pipeline.fit(X, y)
|
||
future = future[[SOURCE_ROW, *features]].copy()
|
||
future["预测值"] = pipeline.predict(future_X)
|
||
tables.append(("新样本预测", future))
|
||
else:
|
||
provenance = None
|
||
return tables, {"algorithm": method, "train_rows": len(train), "test_rows": len(test), "prediction_source": provenance,
|
||
"note": "训练集拟合填补、编码和标准化;固定留出集对比简单基线。特征重要性不是因果影响。新样本预测使用全量训练数据重新拟合。"}
|
||
|
||
|
||
def unsupervised(frame: pd.DataFrame, spec: dict, *, anomaly: bool) -> tuple[list, dict]:
|
||
from sklearn.cluster import DBSCAN, KMeans
|
||
from sklearn.ensemble import IsolationForest
|
||
from sklearn.metrics import silhouette_score
|
||
from sklearn.preprocessing import StandardScaler
|
||
|
||
columns = require_columns(frame, spec["features"])
|
||
data = numeric(frame, columns)
|
||
if len(data) < 3:
|
||
raise ValueError("至少需要 3 条完整数值记录")
|
||
scaled = StandardScaler().fit_transform(data)
|
||
if anomaly:
|
||
contamination = float(spec.get("contamination", .05))
|
||
if not 0 < contamination <= .5:
|
||
raise ValueError("contamination 必须在 (0, 0.5] 之间")
|
||
model = IsolationForest(contamination=contamination, random_state=42, n_jobs=1)
|
||
elif spec.get("algorithm", "kmeans") == "kmeans":
|
||
k = int(spec.get("clusters", 3))
|
||
if not 2 <= k < len(data):
|
||
raise ValueError("clusters 必须至少为 2 且小于样本数")
|
||
model = KMeans(n_clusters=k, n_init=10, random_state=42)
|
||
elif spec["algorithm"] == "dbscan":
|
||
model = DBSCAN(eps=float(spec.get("eps", .5)), min_samples=int(spec.get("min_samples", 5)))
|
||
else:
|
||
raise ValueError("聚类 algorithm 仅支持 kmeans、dbscan")
|
||
labels = model.fit_predict(scaled)
|
||
result = frame.copy()
|
||
result["异常标记" if anomaly else "簇编号"] = labels
|
||
if anomaly:
|
||
result["正常程度得分"] = model.decision_function(scaled)
|
||
score = None
|
||
valid = labels != -1
|
||
if not anomaly and 1 < len(set(labels[valid])) < valid.sum():
|
||
score = float(silhouette_score(scaled[valid], labels[valid], sample_size=min(2000, int(valid.sum())), random_state=42))
|
||
return [("异常检测" if anomaly else "聚类结果", result)], {"silhouette_without_noise": score,
|
||
"note": "数值字段先标准化;-1 表示异常候选或 DBSCAN 噪声,不自动删除。聚类编号没有优劣顺序。"}
|
||
|
||
|
||
def optimize(spec: dict) -> tuple[list, dict]:
|
||
from scipy.optimize import Bounds, LinearConstraint, milp, minimize
|
||
|
||
names = spec["variables"]
|
||
if not isinstance(names, list) or not 1 <= len(names) <= 200 or len(set(names)) != len(names):
|
||
raise ValueError("variables 需为 1–200 个唯一名称")
|
||
n = len(names)
|
||
c = np.asarray(spec["objective"], dtype=float)
|
||
if c.shape != (n,) or not np.isfinite(c).all():
|
||
raise ValueError("objective 需为每个变量的有限数值系数")
|
||
direction = spec.get("sense", "min")
|
||
if direction not in {"min", "max"}:
|
||
raise ValueError("sense 仅支持 min、max")
|
||
sign = 1 if direction == "min" else -1
|
||
limits = spec.get("bounds", [[0, None] for _ in names])
|
||
if len(limits) != n or any(len(bound) != 2 for bound in limits):
|
||
raise ValueError("bounds 需给出每个变量的 [下限, 上限],null 表示无界")
|
||
lower = np.array([-np.inf if x[0] is None else x[0] for x in limits], dtype=float)
|
||
upper = np.array([np.inf if x[1] is None else x[1] for x in limits], dtype=float)
|
||
if np.isnan(lower).any() or np.isnan(upper).any() or (lower > upper).any():
|
||
raise ValueError("变量边界无效")
|
||
constraints = spec.get("constraints", [])
|
||
if len(constraints) > 1000:
|
||
raise ValueError("约束最多 1000 项")
|
||
A, lows, highs = [], [], []
|
||
for constraint in constraints:
|
||
row = np.asarray(constraint["coefficients"], dtype=float)
|
||
rhs, relation = float(constraint["rhs"]), constraint["relation"]
|
||
if row.shape != (n,) or not np.isfinite(row).all() or not math.isfinite(rhs) or relation not in {"<=", ">=", "=="}:
|
||
raise ValueError("约束系数、右端值或 relation 无效")
|
||
A.append(row)
|
||
lows.append(rhs if relation in {">=", "=="} else -np.inf)
|
||
highs.append(rhs if relation in {"<=", "=="} else np.inf)
|
||
A = np.asarray(A).reshape((-1, n))
|
||
linear = LinearConstraint(A, lows, highs) if constraints else None
|
||
integer = spec.get("integer", [False] * n)
|
||
if len(integer) != n or any(type(v) is not bool for v in integer):
|
||
raise ValueError("integer 需为与变量数一致的布尔列表")
|
||
if "quadratic" in spec:
|
||
Q = np.asarray(spec["quadratic"], dtype=float)
|
||
if any(integer) or Q.shape != (n, n) or not np.isfinite(Q).all() or not np.allclose(Q, Q.T):
|
||
raise ValueError("quadratic 需为对称矩阵,二次规划仅支持连续变量")
|
||
if np.linalg.eigvalsh(sign * Q).min() < -1e-9:
|
||
raise ValueError("仅支持凸最小化或凹最大化的二次目标")
|
||
start = np.asarray(spec.get("initial", np.clip(np.zeros(n), lower, upper)), dtype=float)
|
||
if start.shape != (n,) or not np.isfinite(start).all():
|
||
raise ValueError("initial 需为有限数值向量")
|
||
objective = lambda x: float(c @ x + .5 * x @ Q @ x)
|
||
result = minimize(lambda x: sign * objective(x), start, jac=lambda x: sign * (c + Q @ x), method="SLSQP",
|
||
bounds=Bounds(lower, upper), constraints=[linear] if linear else [], options={"maxiter": 1000, "ftol": 1e-9})
|
||
guarantee = "凸二次规划的数值解,已检查可行性"
|
||
else:
|
||
objective = lambda x: float(c @ x)
|
||
result = milp(sign * c, integrality=np.asarray(integer, dtype=int), bounds=Bounds(lower, upper),
|
||
constraints=linear, options={"time_limit": 60., "mip_rel_gap": 0.})
|
||
guarantee = "HiGHS 求解成功;仅在成功且可行时输出方案"
|
||
if not result.success or result.x is None:
|
||
raise ValueError(f"求解未成功,不能输出最优方案:status={result.status}; {result.message}")
|
||
x = result.x
|
||
activity = A @ x
|
||
tolerance = 1e-6
|
||
if (x < lower - tolerance).any() or (x > upper + tolerance).any() or (activity < np.asarray(lows) - tolerance).any() or (activity > np.asarray(highs) + tolerance).any():
|
||
raise ValueError("求解结果未通过约束可行性复核")
|
||
if any(abs(x[i] - round(x[i])) > tolerance for i in range(n) if integer[i]):
|
||
raise ValueError("整数变量未通过整数性复核")
|
||
rows = [[constraint.get("name", f"约束{i+1}"), float(activity[i]), constraint["relation"], constraint["rhs"],
|
||
float(min(activity[i] - lows[i], highs[i] - activity[i]))] for i, constraint in enumerate(constraints)]
|
||
return [("优化方案", pd.DataFrame({"变量": names, "取值": x})),
|
||
("约束复核", pd.DataFrame(rows, columns=["约束", "左端值", "关系", "右端值", "余量"]))], {
|
||
"objective_value": objective(x), "solver_status": int(result.status), "guarantee": guarantee,
|
||
"note": "未自动放松约束;敏感性分析需明确修改参数并重新求解。"}
|
||
|
||
|
||
def model(path: str | None, spec: dict) -> tuple[list, dict]:
|
||
task = spec["task"]
|
||
options = {
|
||
"evaluate": {"entity", "directions", "weighting", "weights", "comparison_matrix"},
|
||
"forecast": {"by", "date", "value", "frequency", "horizon", "methods", "holdout", "seasonal_period"},
|
||
"regression": {"features", "categorical", "target", "algorithm", "test_fraction", "time_column", "predict_source"},
|
||
"classification": {"features", "categorical", "target", "algorithm", "test_fraction", "time_column", "predict_source"},
|
||
"cluster": {"features", "algorithm", "clusters", "eps", "min_samples"},
|
||
"anomaly": {"features", "contamination"},
|
||
"optimize": {"variables", "objective", "sense", "bounds", "integer", "constraints", "quadratic", "initial"},
|
||
}
|
||
if task not in options:
|
||
raise ValueError(f"不支持的 task:{task}")
|
||
unknown = set(spec) - {"task", "source", "chart"} - options[task]
|
||
if unknown:
|
||
raise ValueError(f"模型说明包含未知参数:{sorted(unknown)}")
|
||
source = None
|
||
if task == "optimize":
|
||
tables, details = optimize(spec)
|
||
else:
|
||
if not path:
|
||
raise ValueError("此任务需要 --input")
|
||
frame, source = read_dataset(path, spec.get("source"))
|
||
if frame.empty:
|
||
raise ValueError("建模范围没有数据记录")
|
||
if frame.size > 100_000 or len(frame) > 20_000:
|
||
raise ValueError("建模上限为 20000 行、100000 单元格;请缩小明确的建模范围")
|
||
if task == "evaluate":
|
||
tables, details = evaluate(frame, spec)
|
||
elif task == "forecast":
|
||
tables, details = forecast(frame, spec)
|
||
elif task in {"regression", "classification"}:
|
||
tables, details = supervised(frame, spec, classification=task == "classification")
|
||
elif task in {"cluster", "anomaly"}:
|
||
tables, details = unsupervised(frame, spec, anomaly=task == "anomaly")
|
||
else:
|
||
raise ValueError(f"不支持的 task:{task}")
|
||
return tables, {"task": task, "source": source, "parameters": spec, "results": details,
|
||
"result_kind": "模型结果快照,修改输入后需按相同参数重新运行;不是可自动重算的 Excel 公式。"}
|
||
|
||
|
||
def main() -> dict:
|
||
parser = SkillArgumentParser(description="评价、预测、回归、分类、聚类、异常检测和受控优化;输出 xlsx 写入操作 JSON。")
|
||
parser.add_argument("--input")
|
||
parser.add_argument("--spec")
|
||
parser.add_argument("--spec-file")
|
||
parser.add_argument("--output", required=True)
|
||
parser.add_argument("--overwrite", action="store_true")
|
||
args = parser.parse_args()
|
||
spec = load_json_argument(args.spec, args.spec_file, label="模型说明")
|
||
tables, metadata = model(args.input, spec)
|
||
return save_plan(tables, metadata, args.output, overwrite=args.overwrite, chart=spec.get("chart"))
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(run_cli(main))
|