周三下午,精加工工段试切间。
"这批 45# 钢轴,要求 Ra1.6,"工艺员小郑把一沓试切记录摊在桌角,"每次调完转速和进给,就车几件去测粗糙度,测完再手调。前天 S1200 F0.1 出来 Ra1.4,昨天 S1000 F0.15 出来 Ra2.1,今天想试 S1500 F0.08,又得再车一轮。"
我点开他们导出的试切表。
"这表里有什么?"小郑问。
"每条是一次试切:转速、进给、切深、实测 Ra,"我指着屏幕,"但它就是个记录本,没建模型。想预测'某个转速+进给组合下 Ra 大概多少',现在只能靠老师傅经验拍,或者硬试。试一次就是半小时机床占用 + 一件毛坯。"
"我就想干一件事,"小郑说,"拿历史试切数据训个模型,输入转速和进给,直接吐出预测 Ra,顺便告诉我哪个参数影响大,还能画个曲面看'好参数区'在哪。以后工艺卡不用试切堆出来,按模型反推就行。"
"比如 S1200 F0.1 预测 Ra1.42,实测 1.39;S1000 F0.15 预测 Ra2.08,实测 2.11,"我接话,"误差压到 0.05 以内,模型就够排工艺卡用了。再算特征重要性,进给对 Ra 的影响比转速还大,这跟理论公式对得上。"
"对,"小郑点头,"还想看残差,怕模型在高速区瞎猜;也想看不同材料是不是要分开建模型。"
"用 pandas 读试切数据,numpy 做特征工程,scikit-learn 建线性回归+多项式回归+随机森林做对照,scipy 做显著性检验和置信区间,matplotlib 画散点拟合线+预测曲面+残差图+重要性柱状图,networkx 建'参数-响应'关系网,"我开工程,"数据自包含,合成一批车削试切数据,下载就能跑。"
敲了行原型:
X = df[["spindle_rpm", "feed_mm_rev"]]
model = LinearRegression().fit(X, df["ra"])
# 理论: Ra ∝ f^2 / (8*r*N) 的简化经验映射
"完整版 OOP 封好,"我说,"加载器、特征工程器、回归建模器、显著性分析器、残差诊断器、关系网、出图器,输出预测模型 + 5图 + 报告,存 results/。"
小郑凑近看:"那以后看报告:线性模型 R²=0.93,进给系数显著为正,转速系数显著为负;预测曲面里 Ra≤1.6 的区域是'高转速+低进给'那块绿区;残差在高速区无系统偏置;随机森林特征重要性进给占 0.61;工艺卡直接挂'推荐 S1300~1500 / F0.08~0.10'。"
"对,"我接话,"表面粗糙度不是测出来再调,是模型算出来先定工艺。数字孪生里建切削质量模型,这套回归就是标定底座。"
一、实际应用场景(真实痛点)
场景设定:车削精加工试切阶段,工艺组通过反复调转速/进给/切深验证表面粗糙度,试切成本高、周期长,且经验难以沉淀。需要把历史试切数据转成"参数→Ra"的可解释预测模型,支撑工艺卡编制与参数窗口推荐。
现场原话(叙事化):
"不是我们不会调参数,"小郑说,"是会调但说不清。老师傅说'转速高点进给低点就光',可高到多少、低到多少,全靠试。每次试切都是占机床,客户催货的时候最头疼。"
"还有材料的事,"小郑补充,"45# 和 40Cr 放一起训,模型就糊了,40Cr 黏刀,同参数 Ra 偏高。想按材料分模型,也想知道理论公式和模型是不是对得上。"
核心矛盾:"试切记录流水" 与 "可解释回归模型 + 参数重要性 + 预测曲面 + 残差诊断 + 工艺窗口推荐" 之间的断层。
二、痛点分析(映射到滨州职业学院《先进制造技术》课程模型)
《先进制造技术》模块 本篇痛点对应
数控加工与CAD/CAM技术:车削工艺参数、表面质量、切削用量优化 转速/进给→Ra 建模与工艺窗口
先进制造技术基础:表面粗糙度理论、公差与质量 Ra 预测 + 理论公式对照
FMS与先进生产管理:工艺标准化、工艺卡编制 模型输出推荐参数区,固化到工艺卡
智能制造与数字孪生:切削过程数字模型 回归模型作孪生标定底座
先进制造新模式:数据驱动工艺优化 从试切迭代→模型反推
一句话总结:我们需要一个"车削试切数据→表面粗糙度预测回归程序",用
"pandas" 读数据做特征工程,
"numpy" 做矩阵运算与网格化,
"scikit-learn" 建线性/多项式/随机森林对照模型,
"scipy" 做系数显著性与置信区间,
"matplotlib" 画拟合散点/预测曲面/残差/重要性图,
"networkx" 建参数-响应关系网,实现从"试切记录"到"可解释预测模型 + 工艺窗口推荐"。
三、核心逻辑讲解(大白话)
3.1 问题本质:把车削想成"炒菜调火候"
把车削精加工想成炒一盘菜调火候和翻勺速度:
* 转速 = 火大小(火大,表面更匀更光)
* 进给 = 翻勺频率(翻太勤,表面留刀纹,就糙)
* 切深 = 每次下铲厚度(精车影响小,但也不能忽略)
* Ra = 菜的表面卖相(数值越小越光)
* 理论规律:刀纹高度 ≈ f²/(8R),进给平方级影响,转速反比影响
* 试切记录 = 以前每次炒完拍的照片
* 回归模型 = 把"火候+翻勺→卖相"总结成一张公式表
* 预测曲面 = 画一张"哪块火候组合最光"的地图
* 残差 = 模型猜的和实测差多少,看有没有系统性瞎猜
3.2 业务逻辑 → 代码映射
导入车削试切数据
│
▼ TurningLoader (pandas)
读取 CSV:
spindle_rpm, feed_mm_rev, depth_mm, material, ra
做单位校验, 按材料分组标记
│
▼ FeatureBuilder (numpy/pandas)
特征工程:
X = [转速, 进给]
衍生: 1/转速, 进给², 转速×进给
按材料 one-hot
│
▼ RegressionModeler (sklearn)
多模型对照:
LinearRegression # 线性基线
PolynomialFeatures+Linear # 含交互项
RandomForestRegressor # 非线性对照
输出系数 / 特征重要性 / R² / RMSE
│
▼ SignificanceAnalyzer (scipy + statsmodels思路用scipy实现)
显著性:
系数 t 检验(p值)
模型置信区间
材料间均值差异 t 检验
│
▼ ResidualDiagnostics (numpy)
残差诊断:
残差 vs 预测值(看异方差)
残差分布直方图
高速区偏置检测
│
▼ ProcessWindowRecommender
工艺窗口:
网格扫描转速×进给
筛 Ra<=目标值 区域
输出推荐区间
│
▼ ParamGraph (networkx)
参数-响应关系网:
节点=参数/响应/材料
边权=影响强度(系数绝对值)
│
▼ TurningVisualizer (matplotlib)
可视化:
1. 实测vs预测散点(对角线)
2. 转速×进给→Ra 预测曲面
3. 残差vs预测值散点
4. 特征重要性柱状图
5. 按材料分色散点+拟合线
6. 参数关系网
│
▼ SyntheticTurningGenerator (numpy)
合成数据:
按理论公式+噪声生成多材料试切集
含转速/进给主效应+材料偏置
3.3 为什么不能只看"试切平均值"
视角 问题
单点试切 噪声大,无法外推
手算经验公式 忽略材料/刀具磨损耦合
回归模型+显著性 知道哪个参数真有用
预测曲面 直接圈出合格工艺窗口
残差诊断 确认模型没在高速区系统性失真
3.4 分析前后对比
维度 传统试切 本程序
Ra预测 试完才知 输入参数即出
参数重要性 老师傅经验 系数p值+RF重要性
工艺窗口 反复试 曲面圈绿区
材料差异 混算失真 分模型+偏置检验
可信度评估 无 残差+置信区间
四、OOP 代码实现
4.1 项目结构
turning_ra_predictor/
├── turning_ra_predictor/
│ ├── __init__.py
│ ├── turning_loader.py # 数据加载
│ ├── feature_builder.py # 特征工程
│ ├── regression_modeler.py # 回归建模
│ ├── significance_analyzer.py # 显著性(scipy)
│ ├── residual_diag.py # 残差诊断
│ ├── process_window.py # 工艺窗口推荐
│ ├── param_graph.py # 参数关系网(networkx)
│ ├── visualizer.py # 可视化
│ └── synthetic_data.py # 合成数据
├── tests/
│ ├── __init__.py
│ └── test_turning_ra.py
├── results/
│ ├── pred_vs_actual.png
│ ├── ra_surface.png
│ ├── residual_plot.png
│ ├── feature_importance.png
│ ├── material_scatter.png
│ ├── param_network.png
│ ├── model_metrics.csv
│ ├── process_window.csv
│ ├── coefficients.csv
│ └ turning_report.txt
└── run_turning_analysis.py
4.2 核心源码
<details>
<summary></summary>
"""车削试切数据加载器"""
import pandas as pd
from pathlib import Path
from typing import Optional
class TurningLoader:
"""加载车削试切CSV"""
def __init__(self, filepath: str = "turning_trial.csv",
encoding: str = "utf-8"):
self.filepath = Path(filepath)
self.encoding = encoding
self._raw: Optional[pd.DataFrame] = None
def load(self) -> pd.DataFrame:
if not self.filepath.exists():
raise FileNotFoundError(f"文件不存在: {self.filepath}")
self._raw = pd.read_csv(self.filepath, encoding=self.encoding)
rename = {}
for tgt, al in {
"spindle_rpm": ["spindle_rpm", "转速", "rpm"],
"feed_mm_rev": ["feed_mm_rev", "进给", "f"],
"depth_mm": ["depth_mm", "切深", "ap"],
"material": ["material", "材料", "mat"],
"ra": ["ra", "粗糙度", "Ra", "ra_um"],
}.items():
if tgt not in self._raw.columns:
for a in al:
if a in self._raw.columns:
rename[a] = tgt
break
self._raw = self._raw.rename(columns=rename)
req = ["spindle_rpm", "feed_mm_rev", "ra"]
miss = [c for c in req if c not in self._raw.columns]
if miss:
raise ValueError(f"缺少必要列: {miss}")
self._raw["spindle_rpm"] = pd.to_numeric(self._raw["spindle_rpm"], errors="coerce")
self._raw["feed_mm_rev"] = pd.to_numeric(self._raw["feed_mm_rev"], errors="coerce")
self._raw["depth_mm"] = pd.to_numeric(self._raw.get("depth_mm", 0.2), errors="coerce").fillna(0.2)
self._raw["ra"] = pd.to_numeric(self._raw["ra"], errors="coerce")
self._raw["material"] = self._raw.get("material", "45#").astype(str).str.strip()
self._raw = self._raw.dropna(subset=["spindle_rpm", "feed_mm_rev", "ra"]).copy()
self._raw = self._raw[(self._raw["spindle_rpm"] > 0) &
(self._raw["feed_mm_rev"] > 0)].reset_index(drop=True)
return self._raw
</details>
<details>
<summary></summary>
"""特征工程 (numpy/pandas)"""
import numpy as np
import pandas as pd
from typing import Optional
class FeatureBuilder:
"""
基础特征: 转速, 进给, 切深
衍生特征(贴合理论):
1/rpm 转速倒数(理论反比)
feed^2 进给平方(刀纹高度∝f²)
rpm*feed 交互项
可选: 材料 one-hot
"""
def __init__(self, use_material: bool = True):
self.use_material = use_material
self.feature_names_ = []
def build(self, df: pd.DataFrame,
base_cols: Optional[list] = None) -> pd.DataFrame:
out = df.copy()
out["inv_rpm"] = 1.0 / out["spindle_rpm"]
out["feed_sq"] = out["feed_mm_rev"] ** 2
out["rpm_feed"] = out["spindle_rpm"] * out["feed_mm_rev"]
base = ["spindle_rpm", "feed_mm_rev", "depth_mm",
"inv_rpm", "feed_sq", "rpm_feed"]
if self.use_material and "material" in out.columns:
mats = pd.get_dummies(out["material"], prefix="mat")
out = pd.concat([out, mats], axis=1)
base += list(mats.columns)
self.feature_names_ = base
return out
def matrix(self, df: pd.DataFrame) -> np.ndarray:
if not self.feature_names_:
self.build(df)
return df[self.feature_names_].values.astype(float)
</details>
<details>
<summary></summary>
"""回归建模 (scikit-learn)"""
import numpy as np
import pandas as pd
from sklearn.linear_model import LinearRegression
from sklearn.preprocessing import PolynomialFeatures
from sklearn.pipeline import Pipeline
from sklearn.ensemble import RandomForestRegressor
from sklearn.metrics import r2_score, mean_squared_error, mean_absolute_error
from typing import Dict, Optional
class RegressionModeler:
"""线性 / 多项式 / 随机森林 对照"""
def __init__(self, random_state: int = 42):
self.random_state = random_state
self.models: Dict[str, object] = {}
self.metrics: pd.DataFrame = pd.DataFrame()
def fit_compare(self, X: np.ndarray, y: np.ndarray,
feature_names: list) -> pd.DataFrame:
rows = []
# 1. 线性
lin = LinearRegression().fit(X, y)
self.models["linear"] = lin
rows.append(self._metric("linear", X, y, lin.predict(X)))
# 2. 二阶多项式(含交互)
poly = Pipeline([
("poly", PolynomialFeatures(degree=2, include_bias=False)),
("lin", LinearRegression())
]).fit(X[:, :2], y) # 用转速+进给做多项式
self.models["poly2"] = poly
rows.append(self._metric("poly2", X[:, :2], y, poly.predict(X[:, :2])))
# 3. 随机森林
rf = RandomForestRegressor(n_estimators=200,
random_state=self.random_state).fit(X, y)
self.models["rf"] = rf
rows.append(self._metric("rf", X, y, rf.predict(X)))
self.metrics = pd.DataFrame(rows)
return self.metrics
def _metric(self, name, X, y, pred):
return {
"model": name,
"r2": round(r2_score(y, pred), 4),
"rmse": round(float(np.sqrt(mean_squared_error(y, pred))), 4),
"mae": round(float(mean_absolute_error(y, pred)), 4),
}
def predict_grid(self, model_name: str,
rpm_grid: np.ndarray, feed_grid: np.ndarray,
feature_builder=None, base_df=None) -> np.ndarray:
m = self.models[model_name]
if model_name == "poly2":
Xg = np.column_stack([rpm_grid.ravel(), feed_grid.ravel()])
return m.predict(Xg).reshape(rpm_grid.shape)
# 线性/RF: 用完整特征, 默认材料取众数
Xg = np.column_stack([
rpm_grid.ravel(), feed_grid.ravel(),
np.full(rpm_grid.size, 0.2)
])
# 补衍生特征(简化: 仅主特征版用于曲面展示)
Xs = np.column_stack([
rpm_grid.ravel(), feed_grid.ravel(),
np.full(rpm_grid.size, 0.2),
1.0 / rpm_grid.ravel(),
feed_grid.ravel() ** 2,
rpm_grid.ravel() * feed_grid.ravel(),
])
if model_name == "linear":
# 对齐训练特征数
if hasattr(m, "coef_") and len(m.coef_) == Xs.shape[1]:
return m.predict(Xs).reshape(rpm_grid.shape)
return m.predict(Xg).reshape(rpm_grid.shape)
return m.predict(Xs).reshape(rpm_grid.shape)
</details>
<details>
<summary></summary>
"""系数显著性与材料差异检验 (scipy)"""
import numpy as np
import pandas as pd
from scipy import stats
from typing import Dict, List
class SignificanceAnalyzer:
"""线性回归系数 t 检验(手动实现) + 材料均值差异"""
def __init__(self, alpha: float = 0.05):
self.alpha = alpha
def coef_test(self, X: np.ndarray, y: np.ndarray,
feature_names: list) -> pd.DataFrame:
n, p = X.shape
model = np.linalg.lstsq(X, y, rcond=None)[0]
yhat = X @ model
resid = y - yhat
dof = max(n - p - 1, 1)
sigma2 = np.sum(resid ** 2) / dof
xtx_inv = np.linalg.inv(X.T @ X)
se = np.sqrt(np.diag(sigma2 * xtx_inv))
tvals = model / (se + 1e-12)
pvals = 2 * (1 - stats.t.cdf(np.abs(tvals), dof))
rows = []
for i, name in enumerate(feature_names):
rows.append({
"feature": name,
"coef": round(float(model[i]), 6),
"std_err": round(float(se[i]), 6),
"t_value": round(float(tvals[i]), 3),
"p_value": round(float(pvals[i]), 4),
"significant": bool(pvals[i] < self.alpha),
})
return pd.DataFrame(rows).sort_values("p_value").reset_index(drop=True)
def material_ttest(self, df: pd.DataFrame,
mat_a: str, mat_b: str,
target: str = "ra") -> Dict:
a = df[df["material"] == mat_a][target].values
b = df[df["material"] == mat_b][target].values
if len(a) < 2 or len(b) < 2:
return {"a": mat_a, "b": mat_b, "p_value": np.nan}
stat, p = stats.ttest_ind(a, b, equal_var=False)
return {
"a": mat_a, "b": mat_b,
"mean_a": round(float(a.mean()), 3),
"mean_b": round(float(b.mean()), 3),
"p_value": round(float(p), 4),
"differs": bool(p < self.alpha),
}
</details>
<details>
<summary></summary>
"""残差诊断 (numpy)"""
import numpy as np
import pandas as pd
from typing import Optional
class ResidualDiagnostics:
"""残差 vs 预测 / 分布 / 分区偏置"""
def __init__(self):
pass
def analyze(self, y_true, y_pred, rpm=None) -> pd.DataFrame:
resid = np.asarray(y_true) - np.asarray(y_pred)
out = pd.DataFrame({
"y_pred": np.asarray(y_pred).round(4),
"y_true": np.asarray(y_true).round(4),
"residual": resid.round(4),
})
if rpm is not None:
out["spindle_rpm"] = np.asarray(rpm)
# 高速区偏置
hi = out[out["spindle_rpm"] > np.median(rpm)]
lo = out[out["spindle_rpm"] <= np.median(rpm)]
self.high_speed_bias = float(hi["residual"].mean())
self.low_speed_bias = float(lo["residual"].mean())
self.resid = resid
self.rmse = float(np.sqrt(np.mean(resid ** 2)))
return out
def summary(self) -> dict:
return {
"rmse": round(self.rmse, 4),
"resid_mean": round(float(np.mean(self.resid)), 5),
"resid_std": round(float(np.std(self.resid, ddof=1)), 4),
"high_speed_bias": round(getattr(self, "high_speed_bias", 0.0), 5),
"low_speed_bias": round(getattr(self, "low_speed_bias", 0.0), 5),
}
</details>
<details>
<summary></summary>
"""工艺窗口推荐"""
import numpy as np
import pandas as pd
from typing import Optional
class ProcessWindowRecommender:
"""网格扫描转速×进给, 筛 Ra<=target 区域"""
def __init__(self, target_ra: float = 1.6):
self.target_ra = target_ra
def recommend(self, model, rpm_range=(800, 2000),
feed_range=(0.05, 0.25), n=40) -> dict:
rpms = np.linspace(*rpm_range, n)
feeds = np.linspace(*feed_range, n)
R, F = np.meshgrid(rpms, feeds)
# 用线性模型完整特征版
Xg = np.column_stack([
R.ravel(), F.ravel(),
np.full(R.size, 0.2),
1.0 / R.ravel(),
F.ravel() ** 2,
R.ravel() * F.ravel(),
])
if hasattr(model, "coef_") and len(model.coef_) == Xg.shape[1]:
pred = model.predict(Xg)
else:
Xg2 = np.column_stack([R.ravel(), F.ravel()])
pred = model.predict(Xg2)
Z = pred.reshape(R.shape)
mask = Z <= self.target_ra
ok_rpm = R[mask]
ok_feed = F[mask]
rec = {
"grid_R": R, "grid_F": F, "grid_Z": Z,
"valid_mask": mask,
"rpm_min": float(ok_rpm.min()) if ok_rpm.size else np.nan,
"rpm_max": float(ok_rpm.max()) if ok_rpm.size else np.nan,
"feed_min": float(ok_feed.min()) if ok_feed.size else np.nan,
"feed_max": float(ok_feed.max()) if ok_feed.size else np.nan,
"valid_count": int(mask.sum()),
}
return rec
def to_df(self, rec: dict) -> pd.DataFrame:
R, F, Z, mask = (rec["grid_R"], rec["grid_F"],
rec["grid_Z"], rec["valid_mask"])
rows = []
for i in range(R.shape[0]):
for j in range(R.shape[1]):
if mask[i, j]:
rows.append({
"spindle_rpm": round(float(R[i, j]), 1),
"feed_mm_rev": round(float(F[i, j]), 3),
"pred_ra": round(float(Z[i, j]), 3),
})
return pd.DataFrame(rows)
</details>
<details>
<summary></summary>
"""参数-响应关系网 (networkx)"""
import networkx as nx
import pandas as pd
from typing import Optional
class ParamGraph:
"""建 参数-响应-材料 关系网, 边权=影响强度"""
def __init__(self):
self.G = nx.DiGraph()
def build(self, coef_df: pd.DataFrame,
target: str = "Ra") -> nx.DiGraph:
self.G.clear()
self.G.add_node(target, ntype="response")
for _, r in coef_df.iterrows():
fname = r["feature"]
self.G.add_node(fname, ntype="param")
w = abs(float(r["coef"])) * 1000
self.G.add_edge(fname, target, weight=round(w, 3),
sign="+" if r["coef"] > 0 else "-",
p=r["p_value"])
if r["p_value"] < 0.05:
self.G.edges[fname, target]["sig"] = True
return self.G
def strong_edges(self) -> pd.DataFrame:
rows = []
for u, v, d in self.G.edges(data=True):
if d.get("sig", False):
rows.append({"param": u, "response": v,
"sign": d["sign"], "weight": d["weight"],
"p_value": d["p_value"]})
return pd.DataFrame(rows).sort_values("weight", ascending=False).reset_index(drop=True)
</details>
<details>
<summary></summary>
"""可视化 (matplotlib)"""
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from pathlib import Path
plt.rcParams["font.sans-serif"] = ["SimHei", "DejaVu Sans"]
plt.rcParams["axes.unicode_minus"] = False
class TurningVisualizer:
def __init__(self, results_dir: str = "results"):
self.results_dir = Path(results_dir)
self.results_dir.mkdir(exist_ok=True)
def pred_vs_actual(self, y_true, y_pred):
fig, ax = plt.subplots(figsize=(7, 7))
ax.scatter(y_true, y_pred, c="#2980B9", edgecolors="black",
linewidths=0.4, s=50, alpha=0.8)
lo = min(y_true.min(), y_pred.min())
hi = max(y_true.max(), y_pred.max())
ax.plot([lo, hi], [lo, hi], "r--", lw=1.2, label="理想对角线")
ax.set_xlabel("实测 Ra (μm)")
ax.set_ylabel("预测 Ra (μm)")
ax.set_title("实测 vs 预测表面粗糙度", fontsize=13, fontweight="bold")
ax.legend(); ax.grid(alpha=0.3)
plt.tight_layout()
plt.savefig(self.results_dir / "pred_vs_actual.png", dpi=150, bbox_inches="tight")
plt.close()
def surface(self, rec, target_ra=1.6):
R, F, Z = rec["grid_R"], rec["grid_F"], rec["grid_Z"]
fig = plt.figure(figsize=(11, 8))
ax = fig.add_subplot(111, projection="3d")
surf = ax.plot_surface(R, F, Z, cmap="viridis",
alpha=0.9, antialiased=True)
ax.contour(R, F, Z, levels=[target_ra], colors="red",
linestyles="--", offset=Z.min(), zdir="z")
ax.set_xlabel("转速 rpm")
ax.set_ylabel("进给 mm/rev")
ax.set_zlabel("预测 Ra (μm)")
ax.set_title("转速×进给→Ra 预测曲面", fontsize=13, fontweight="bold")
fig.colorbar(surf, ax=ax, shrink=0.6, label="Ra(μm)")
plt.tight_layout()
plt.savefig(self.results_dir / "ra_surface.png", dpi=150, bbox_inches="tight")
plt.close()
def residual(self, diag_df):
fig, axes = plt.subplots(1, 2, figsize=(13, 5))
axes[0].scatter(diag_df["y_pred"], diag_df["residual"],
c="#16A085", s=40, edgecolors="black", linewidths=0.3)
axes[0].axhline(0, color="red", ls="--", lw=1)
axes[0].set_xlabel("预测 Ra")
axes[0].set_ylabel("残差")
axes[0].set_title("残差 vs 预测值", fontweight="bold")
axes[0].grid(alpha=0.3)
axes[1].hist(diag_df["residual"], bins=20, color="#3498DB",
edgecolor="white")
axes[1].set_xlabel("残差")
axes[1].set_ylabel("频次")
axes[1].set_title("残差分布", fontweight="bold")
利用AI解决实际问题,如果你觉得这个工具好用,欢迎关注长安牧笛!