今天分享一种“玻璃质感散点图”的Python绘制方法。传统散点图过于单调,我们将将阴影、彩色光晕、半透明主体、深色轮廓、内部柔光和白色高光逐层叠加,让普通圆形散点呈现出轻微悬浮、通透且具有立体感的效果。测试的话我们采用ANN真实值—预测值对比图为例,使用蓝色和粉色区分训练集与测试集,并添加1∶1参考线以及R²、RMSE和MAE等辅助信息。玻璃质感散点的绘制函数可以独立复用,只需替换横纵坐标数据和颜色,就能应用到相关性分析、模型预测、分组对比等其他科研图形中。最终图片会同时保存为600 dpi PNG和可编辑PDF首先导入路径管理、数值计算、表格处理和绘图需要的库。代码只需要Matplotlib、NumPy和pandas,不依赖scikit-learn。pip install matplotlib numpy pandas
脚本会以当前Python文件所在位置作为根目录,从data文件夹读取figure_a_scatter.csv,并将结果保存到result文件夹中。plot_A_python.pydata/figure_a_scatter.csvresult/
其中result文件夹不需要提前创建,程序运行时会自动生成。”””Reproduce panel A: observed versus ANN-predicted concentrations.The marker is deliberately drawn in several layers (halo, drop shadow,translucent body, rim and a small specular highlight) to reproduce the soft,glass-like points in the reference figure.公众号、小红薯、dou音:地数空间 -微信小程序:地学小助手作者:太阳花🌻
”””from pathlib import Pathimport matplotlib.pyplot as pltfrom matplotlib import transformsimport numpy as npimport pandas as pdROOT = Path(__file__).resolve().parentDATA_FILE = ROOT / ”data” / ”figure_a_scatter.csv”RESULT_DIR = ROOT / ”result”
set_paper_style函数用于控制整张图片的字体、字号、坐标轴粗细、刻度方向和输出格式。字体优先使用Times New Roman;如果当前电脑没有安装,则依次尝试Times和DejaVu Serif。横纵坐标标题设置为15号,刻度标签设置为11.5号,整体更加接近期刊图件的排版比例。pdf.fonttype和ps.fonttype被设置为42,可以在PDF中嵌入TrueType字体,方便后续使用Adobe Illustrator等软件继续编辑。def set_paper_style() -> None: ”””Set a compact journal-figure style shared by the whole panel.””” plt.rcParams.update( { ”font.family”: ”serif”, ”font.serif”: [”Times New Roman”, ”Times”, ”DejaVu Serif”], ”mathtext.fontset”: ”custom”, ”mathtext.rm”: ”Times New Roman”, ”mathtext.it”: ”Times New Roman:italic”, ”mathtext.bf”: ”Times New Roman:bold”, ”mathtext.cal”: ”Times New Roman”, ”mathtext.default”: ”regular”, ”font.size”: 11.5, ”axes.labelsize”: 15, ”axes.labelweight”: ”normal”, ”axes.linewidth”: 1.05, ”xtick.labelsize”: 11.5, ”ytick.labelsize”: 11.5, ”xtick.direction”: ”out”, ”ytick.direction”: ”out”, ”xtick.major.size”: 4.5, ”ytick.major.size”: 4.5, ”xtick.major.width”: 1.0, ”ytick.major.width”: 1.0, ”pdf.fonttype”: 42, ”ps.fonttype”: 42, ”savefig.facecolor”: ”white”, ”axes.facecolor”: ”white”, } )
普通的scatter函数通常只能绘制单层散点,而参考图中的点具有阴影、透明度、轮廓和高光,看起来更有立体感。glass_scatter函数对同一组坐标连续绘制五层图形。第一层是向右下方轻微偏移的深色阴影;第二层是面积稍大的彩色光晕;第三层是带深色边缘的半透明主体;第四层是在散点内部增加柔和的白色区域;第五层是向左上方偏移的小型白色高光。通过多层标记叠加,最终形成类似半透明玻璃球的效果。size控制散点整体大小,color控制主体颜色,edge控制外轮廓颜色。def glass_scatter( ax: plt.Axes, x: np.ndarray, y: np.ndarray, color: str, edge: str, size: float = 62, zorder: float = 3,) -> None: ”””Draw softly translucent, outlined markers with a tiny highlight.”””# A subtle down-right offset makes the translucent circle feel lifted. shadow_offset = transforms.ScaledTranslation( 0.90 / 72, -1.00 / 72, ax.figure.dpi_scale_trans ) ax.scatter( x, y, s=size * 1.22, color=”#17242b”, alpha=0.13, linewidths=0, transform=ax.transData + shadow_offset, zorder=zorder - 0.20, clip_on=True, )# Diffuse coloured halo, then a translucent body and a crisp dark rim. ax.scatter( x, y, s=size * 1.48, color=color, alpha=0.10, linewidths=0, zorder=zorder - 0.15, ) ax.scatter( x, y, s=size, facecolor=color, edgecolor=edge, alpha=0.72, linewidth=0.85, zorder=zorder, ) ax.scatter( x, y, s=size * 0.48, facecolor=”white”, edgecolor=”none”, alpha=0.075, zorder=zorder + 0.05, ) highlight_offset = transforms.ScaledTranslation( -1.25 / 72, 1.30 / 72, ax.figure.dpi_scale_trans ) ax.scatter( x, y, s=max(size * 0.105, 3.2), color=”white”, alpha=0.58, linewidths=0, transform=ax.transData + highlight_offset, zorder=zorder + 0.1, clip_on=True, )
由于数据点使用了多层叠加效果,Matplotlib的普通图例无法完整保留这种玻璃质感。因此,代码没有直接使用legend函数,而是专门建立glass_legend_marker函数。该函数与真实散点使用相同的阴影、光晕、主体、轮廓和高光结构,只是坐标系统从数据坐标改为坐标轴相对坐标。这样可以精确控制训练集和测试集图例在图内的位置。def glass_legend_marker( ax: plt.Axes, x: float, y: float, color: str, edge: str, size: float = 53,) -> None: ”””Draw an axes-coordinate key with the same rim and highlight as the data.””” shadow_offset = transforms.ScaledTranslation( 0.78 / 72, -0.82 / 72, ax.figure.dpi_scale_trans ) ax.scatter( [x], [y], transform=ax.transAxes + shadow_offset, s=size * 1.22, color=”#17242b”, alpha=0.13, linewidths=0, clip_on=False, zorder=5.80, ) ax.scatter( [x], [y], transform=ax.transAxes, s=size * 1.48, color=color, alpha=0.10, linewidths=0, clip_on=False, zorder=5.85, ) ax.scatter( [x], [y], transform=ax.transAxes, s=size, facecolor=color, edgecolor=edge, alpha=0.72, linewidth=0.85, clip_on=False, zorder=6.00, ) ax.scatter( [x], [y], transform=ax.transAxes, s=size * 0.48, facecolor=”white”, edgecolor=”none”, alpha=0.075, clip_on=False, zorder=6.05, ) highlight_offset = transforms.ScaledTranslation( -1.05 / 72, 1.08 / 72, ax.figure.dpi_scale_trans ) ax.scatter( [x], [y], transform=ax.transAxes + highlight_offset, s=max(size * 0.105, 3.2), color=”white”, alpha=0.58, linewidths=0, clip_on=False, zorder=6.10, )
metrics函数根据真实值和预测值计算R²、RMSE和MAE。R²反映模型对观测值变化的解释能力,越接近1,说明预测值与真实值整体越一致。RMSE是均方根误差,对较大的预测偏差更加敏感;MAE是平均绝对误差,更直观地反映模型平均偏离真实值的程度。RMSE和MAE越小,通常表示预测误差越低。这里直接使用NumPy按照公式计算,不需要额外调用scikit-learn。需要注意,当某一组所有观测值完全相同时,R²的分母会变为0,因此实际数据应具有一定变化范围。def metrics(observed: np.ndarray, predicted: np.ndarray) -> tuple[float, float, float]: ”””Return R-squared, RMSE and MAE using the definitions in the source data.””” residual = predicted - observed r_squared = 1.0 - np.sum(residual**2) / np.sum((observed - observed.mean()) ** 2) rmse = np.sqrt(np.mean(residual**2)) mae = np.mean(np.abs(residual)) return r_squared, rmse, mae
main函数是整套程序的执行主体。程序首先调用前面的期刊风格设置,然后读取CSV数据并自动创建结果文件夹。set:标记样本属于Training还是Test;observation_mg_per_L:实际观测值;ann_prediction_mg_per_L:ANN模型预测值。图形尺寸被设置为6.0×4.95英寸,并通过subplots_adjust调整四周留白。set_box_aspect设置绘图区宽高比例,使最终坐标框与参考图一致。def main() -> None: set_paper_style() data = pd.read_csv(DATA_FILE) RESULT_DIR.mkdir(parents=True, exist_ok=True)# The reference panel's plotting box is distinctly wider than tall# (approximately 1.24:1), even though both axes cover the same 0--10 range. fig, ax = plt.subplots(figsize=(6.0, 4.95), constrained_layout=False) fig.subplots_adjust(left=0.145, right=0.985, bottom=0.135, top=0.945) ax.set_box_aspect(1 / 1.24)
灰色对角线表示理想状态下“预测值等于真实值”。散点落在线上说明预测完全准确;位于参考线上方表示模型高估,位于参考线下方则表示模型低估。训练集使用蓝色,测试集使用粉红色。程序根据set列筛选两组数据,再分别调用glass_scatter函数完成绘制。需要注意,CSV中set列的内容必须准确填写为Training或Test,包括大小写也要保持一致,否则程序无法识别对应数据。# Thin, pale 1:1 reference line, visually behind every point. ax.plot([0, 10], [0, 10], color=”#c9cdd0”, lw=1.05, zorder=0) styles = { ”Training”: {”color”: ”#2185a7”, ”edge”: ”#12617d”}, ”Test”: {”color”: ”#ca6880”, ”edge”: ”#a74361”}, } for set_name in (”Training”, ”Test”): group = data.loc[data[”set”] == set_name] glass_scatter( ax, group[”observation_mg_per_L”].to_numpy(), group[”ann_prediction_mg_per_L”].to_numpy(), **styles[set_name], )
参考图的横纵坐标范围均为0—10,主刻度间隔为2。横坐标表示实际观测浓度,纵坐标表示ANN预测浓度,单位均为mg/L。代码关闭顶部和右侧刻度,但保留完整的四边坐标框。所有边框统一设置为深灰色,线宽为1.05,使图形保持简洁、规范的期刊风格。如果自己的数据范围并不是0—10,需要同步修改set_xlim、set_ylim、set_xticks、set_yticks以及前面的1∶1参考线坐标。ax.set_xlim(0, 10) ax.set_ylim(0, 10) ax.set_xticks(np.arange(0, 11, 2)) ax.set_yticks(np.arange(0, 11, 2)) ax.set_xlabel(”Observation (mg/L)”, labelpad=8) ax.set_ylabel(”ANN Prediction (mg/L)”, labelpad=8) ax.tick_params(top=False, right=False, pad=3) for spine in ax.spines.values(): spine.set_color(”#303437”) spine.set_linewidth(1.05)
程序再次按照set列提取训练集和测试集,并调用metrics函数计算两组数据的R²、RMSE和MAE。训练集指标主要反映模型对参与训练样本的拟合程度,测试集指标则更能说明模型对新样本的泛化能力。如果训练集表现非常好,而测试集指标明显下降,可能意味着模型存在过拟合。评价模型时不能只看R²,还要结合RMSE、MAE、训练集与测试集差异,以及散点在1∶1线两侧的分布共同判断。# Stacked in-panel labels match the compact layout of the reference panel. text_x = 0.078 legend_x = 0.020 training = data.loc[data[”set”] == ”Training”] test = data.loc[data[”set”] == ”Test”] train_stats = metrics( training[”observation_mg_per_L”].to_numpy(), training[”ann_prediction_mg_per_L”].to_numpy(), ) test_stats = metrics( test[”observation_mg_per_L”].to_numpy(), test[”ann_prediction_mg_per_L”].to_numpy(), )
训练集图例被放置在坐标轴内部左上方。程序先绘制一个蓝色玻璃质感标记,再添加“Training set”文字,随后将训练集的R²、RMSE和MAE按照两行形式显示。所有指标均由前面的计算结果自动生成并保留两位小数,因此更换输入数据后不需要手动修改图中文字。text_x和legend_x分别控制文字与图例点的横向位置,0.955和0.902等参数用于控制纵向位置。glass_legend_marker( ax, legend_x, 0.955, styles[”Training”][”color”], styles[”Training”][”edge”], ) ax.text( text_x, 0.955, ”Training set”, transform=ax.transAxes, ha=”left”, va=”center”, fontsize=10.7, color=”#151719”, ) ax.text( text_x, 0.902, rf”$R^2$={train_stats[0]:.2f} RMSE={train_stats[1]:.2f}” ”\n” rf”MAE={train_stats[2]:.2f}”, transform=ax.transAxes, ha=”left”, va=”top”, fontsize=10.7, linespacing=1.18, color=”#151719”, )
测试集图例位于训练集信息下方,采用粉红色玻璃标记。测试集的R²、RMSE和MAE同样会自动写入图片。将两组指标纵向排列,可以在不额外增加图例框的情况下,直接比较模型在训练集和测试集上的表现,同时避免图例遮挡主要散点。glass_legend_marker( ax, legend_x, 0.770, styles[”Test”][”color”], styles[”Test”][”edge”], ) ax.text( text_x, 0.770, ”Test set”, transform=ax.transAxes, ha=”left”, va=”center”, fontsize=10.7, color=”#151719”, ) ax.text( text_x, 0.717, rf”$R^2$={test_stats[0]:.2f} RMSE={test_stats[1]:.2f}” ”\n” rf”MAE={test_stats[2]:.2f}”, transform=ax.transAxes, ha=”left”, va=”top”, fontsize=10.7, linespacing=1.18, color=”#151719”, )
代码在绘图区左上角外侧添加“(A)”子图编号,便于后续与其他模型或其他指标图组合成多面板图。最终图片会分别保存为figure_A_python.png和figure_A_python.pdf。其中PNG按照600 dpi输出,可以直接用于论文和公众号;PDF保留矢量文字与图形,适合后期排版。保存完成后主动关闭画布,避免批量生成多张图时占用过多内存。程序还会在控制台输出图片位置,以及训练集和测试集的具体评价指标,方便核对。ax.text( -0.095, 1.035, ”(A)”, transform=ax.transAxes, ha=”left”, va=”center”, fontsize=17, fontweight=”normal”, color=”#17191b”, clip_on=False, ) png_file = RESULT_DIR / ”figure_A_python.png” pdf_file = RESULT_DIR / ”figure_A_python.pdf” fig.savefig(png_file, dpi=600) fig.savefig(pdf_file) plt.close(fig) print(f”Saved: {png_file}”) print(f”Saved: {pdf_file}”) print( f”Training: R2={train_stats[0]:.3f}, RMSE={train_stats[1]:.3f}, ” f”MAE={train_stats[2]:.3f}” ) print( f”Test: R2={test_stats[0]:.3f}, RMSE={test_stats[1]:.3f}, ” f”MAE={test_stats[2]:.3f}” )
最后通过Python标准入口判断调用main函数。这样直接运行plot_A_python.py时会执行完整绘图流程;如果以后将该文件作为模块导入其他Python程序,则不会自动执行main函数。if __name__ == ”__main__”: main()
第一,将预测数据保存为data/figure_a_scatter.csv,或者直接修改DATA_FILE指向自己的CSV文件。第二,输入数据需要包含set、observation_mg_per_L和ann_prediction_mg_per_L三列。第三,set列中的训练集必须写为Training,测试集必须写为Test。第四,如果变量单位不是mg/L,需要修改横纵坐标标题中的单位。第五,如果数据范围不是0—10,需要同时修改横纵坐标范围、刻度和1∶1参考线。第六,可以在styles中修改训练集和测试集的颜色,也可以通过glass_scatter函数中的size和alpha参数调整散点大小与透明度。完整代码与测试数据免费无套路在微信小程序“地学小助手”直接免费获取!