Python实战:5步搞定深度强化学习论文中的阴影折线图(附完整代码)

如果你正在撰写深度强化学习相关的论文,或者需要复现顶级会议论文中的实验结果图,那么对“阴影折线图”一定不会陌生。这种图表几乎成了展示算法性能与稳定性的标准配置,一条优雅的曲线,配上恰到好处的阴影区域,既能体现平均趋势,又能直观反映多次实验的波动范围。然而,从数据到最终图表,中间藏着不少“坑”:原始数据噪声大、不同实验的横轴不对齐、该用标准差还是标准误差?阴影到底代表什么?很多论文对此语焉不详,而网上零散的教程又难以提供一个端到端的、可直接复用的解决方案。

这篇文章就是为你准备的。我们不谈空洞的理论,直接从实战出发,面向研究生和算法工程师,解决“如何用Python完整、优雅地复现论文级图表”这个具体问题。我将拆解整个过程为五个清晰的步骤,并提供每一步的完整代码。你将学到的不只是调用一个绘图函数,而是理解数据平滑、对齐、统计量计算背后的逻辑,从而能够灵活应对各种复杂的实验数据,绘制出既专业又美观的图表。

1. 理解核心:阴影究竟在表达什么?

在动手写代码之前,我们必须先厘清图表中每个元素的统计学含义。这绝非吹毛求疵,因为错误的理解会导致错误的图表,进而可能误导读者对你算法稳定性的判断。

一张典型的阴影折线图包含两个核心部分:中心趋势线围绕它的阴影区域。中心趋势线通常代表多次独立实验(例如,不同随机种子)在某个指标(如平均回报)上的集中趋势,而阴影区域则用于量化这种趋势的不确定性离散程度

1.1 中心趋势线的常见选择

  • 平均值 (Mean): 最常用的指标,计算所有实验数据在某一时间点的算术平均。它对极端值(异常值)比较敏感。
  • 中位数 (Median): 将所有数据排序后位于中间的值。它对异常值不敏感,当数据分布可能偏斜时,中位数比平均值更能代表“典型”情况。

注意:在强化学习中,由于训练过程的不稳定性,偶尔会出现个别实验的回报远高于或低于其他实验的情况。此时,使用中位数作为趋势线可能比平均值更稳健。

1.2 阴影区域的多种含义

这是最容易产生混淆的地方。阴影的宽度代表了数据的波动范围,但其具体计算方式不同,传达的信息也截然不同。

阴影含义 计算公式 (样本) 传达的信息 适用场景
标准差 (Standard Deviation) $s = \sqrt{\frac{1}{n-1} \sum_{i=1}^{n} (x_i - \bar{x})^2}$ 描述单次实验结果相对于平均值的典型波动范围。阴影宽度不随实验次数增加而显著减小。 展示算法在单次运行中表现的固有波动性
标准误差 (Standard Error) $SE = \frac{s}{\sqrt{n}}$ 描述样本平均值的估计精度。阴影宽度会随着实验次数 n 的增加而减小。 展示我们对“真实平均性能”估计的不确定度。常用于推断。
分位数区间 (Quantile Range) 例如,25%分位数到75%分位数 描述数据的分布范围,不受极端值影响。上下阴影宽度可能不对称。 当数据分布非正态或存在异常值时,能更稳健地展示数据主体分布。
最大值/最小值范围 [min(data), max(data)] 展示所有实验结果的全距 直观显示所有可能的结果边界,但极易受异常值影响,显得范围很宽。
置信区间 (Confidence Interval) $\bar{x} \pm t_{\alpha/2, df} \cdot SE$ 在给定置信水平(如95%)下,总体均值可能落入的范围。 进行统计推断,如判断算法A的平均性能是否显著优于算法B。

如何选择?

  • 如果你想强调“算法每次跑起来结果大概会在这个范围内波动”,用标准差
  • 如果你想说明“我们估计的平均性能大概在这个精度内”,用标准误差置信区间
  • 如果你的数据中有个别“跑飞”的实验,想关注大多数实验的表现,用分位数区间
  • 在深度强化学习论文中,使用平均值±标准差中位数±分位数是两种非常主流且被广泛接受的做法。务必在图注中明确说明你使用的具体方法。

2. 数据准备:从原始日志到规整数组

我们的起点通常是训练过程中保存的日志文件,格式可能是JSONCSVTensorBoardevents文件。数据往往不“干净”,直接绘图效果很差。

2.1 读取与解析数据

假设我们有5个随机种子下的实验,每个实验的日志是一个CSV文件,包含stepeval_reward两列。

import numpy as np
import pandas as pd
import os
from pathlib import Path

def load_experiment_data(log_dir, num_seeds=5):
    """
    加载多个随机种子下的实验数据。
    
    参数:
        log_dir: 日志文件所在的根目录。
        num_seeds: 随机种子数量。
    
    返回:
        all_curves: 一个列表,每个元素是一个二维numpy数组 [steps, rewards]。
    """
    all_curves = []
    log_path = Path(log_dir)
    
    for seed in range(1, num_seeds + 1):
        # 假设文件名为 seed_1.csv, seed_2.csv ...
        file_path = log_path / f'seed_{seed}.csv'
        if not file_path.exists():
            print(f"警告: 文件 {file_path} 不存在,跳过。")
            continue
            
        df = pd.read_csv(file_path)
        # 确保列名正确,这里假设为 'step' 和 'eval_reward'
        # 实际中可能需要根据你的日志格式调整
        try:
            data = df[['step', 'eval_reward']].to_numpy()
        except KeyError:
            # 尝试自动检测列名
            data = df.iloc[:, :2].to_numpy() # 取前两列
        all_curves.append(data)
    
    if len(all_curves) == 0:
        raise ValueError(f"在目录 {log_dir} 下未找到任何数据文件。")
    
    print(f"成功加载 {len(all_curves)} 条实验曲线。")
    return all_curves

# 使用示例
log_directory = "./experiment_logs/dqn_pong"
curves = load_experiment_data(log_directory, num_seeds=5)

2.2 解决横轴不对齐问题

不同实验的评估点(step)很可能不同。实验A可能在[0, 1000, 2000, ...]步评估,实验B可能在[50, 1050, 2050, ...]步评估。为了计算同一时间点上的统计量,我们需要进行重采样(Resampling),将所有曲线对齐到一组统一的横坐标上。

def resample_curves(all_curves, n_points=500):
    """
    将多条曲线重采样到相同的横坐标点上。
    
    参数:
        all_curves: 列表,每个元素是 [steps, values] 的数组。
        n_points: 目标重采样的点数。
    
    返回:
        common_steps: 统一的横坐标数组,形状 (n_points, )。
        resampled_values: 重采样后的值数组,形状 (n_curves, n_points)。
    """
    # 1. 确定所有曲线的全局最小和最大步数
    all_steps = np.concatenate([curve[:, 0] for curve in all_curves])
    global_min_step = np.min(all_steps)
    global_max_step = np.max(all_steps)
    
    # 2. 生成统一的横坐标(线性空间)
    common_steps = np.linspace(global_min_step, global_max_step, n_points)
    
    # 3. 对每条曲线进行线性插值,得到在新横坐标上的值
    resampled_values_list = []
    for curve in all_curves:
        steps, values = curve[:, 0], curve[:, 1]
        # 使用np.interp进行一维线性插值
        # 要求steps是单调递增的,这通常符合日志数据
        interp_values = np.interp(common_steps, steps, values, left=np.nan, right=np.nan)
        resampled_values_list.append(interp_values)
    
    # 转换为二维数组
    resampled_values = np.array(resampled_values_list) # shape: (n_curves, n_points)
    
    # 处理可能存在的NaN值(例如,插值范围外的点)
    # 简单策略:用该曲线的第一个有效值向前填充,最后一个有效值向后填充
    for i in range(resampled_values.shape[0]):
        valid_mask = ~np.isnan(resampled_values[i])
        if np.any(valid_mask):
            first_valid_idx = np.where(valid_mask)[0][0]
            last_valid_idx = np.where(valid_mask)[0][-1]
            resampled_values[i, :first_valid_idx] = resampled_values[i, first_valid_idx]
            resampled_values[i, last_valid_idx:] = resampled_values[i, last_valid_idx]
        # 如果整条线都是NaN(理论上不应该),则填充为0
        else:
            resampled_values[i, :] = 0
    
    return common_steps, resampled_values

# 使用示例
common_steps, aligned_rewards = resample_curves(curves, n_points=400)
print(f"统一横坐标形状: {common_steps.shape}")
print(f"对齐后的奖励值形状: {aligned_rewards.shape}") # (5, 400)

3. 数据平滑:驯服训练曲线中的噪声

原始的训练曲线通常噪声很大,锯齿状明显,直接绘制会掩盖真正的学习趋势。我们需要进行平滑处理。指数移动平均(Exponential Moving Average, EMA) 是一种非常有效且常用的方法,它给予近期数据更高的权重。

3.1 实现对称指数移动平均

Baselines等开源库中常用一种“对称EMA”技术,它同时考虑了过去和未来的数据来进行平滑,效果比单向EMA更好。

def symmetric_ema(x, y, smooth_coef=0.6, n_points=None):
    """
    对称指数移动平均平滑。
    参考自 openai/baselines 中的实现逻辑。
    
    参数:
        x: 原始横坐标,一维数组。
        y: 原始纵坐标,一维数组。
        smooth_coef: 平滑系数,介于0和1之间。越大越平滑,但滞后越明显。
        n_points: 平滑后输出的点数。如果为None,则使用原始点数。
    
    返回:
        x_smooth: 平滑后的横坐标。
        y_smooth: 平滑后的纵坐标。
    """
    # 如果未指定n_points,则使用原始长度
    if n_points is None:
        n_points = len(x)
    
    # 1. 前向EMA(从开始到结束)
    y_ema_forward = np.zeros_like(y)
    cur = y[0]
    for i in range(len(y)):
        cur = smooth_coef * cur + (1 - smooth_coef) * y[i]
        y_ema_forward[i] = cur
    
    # 2. 后向EMA(从结束到开始)
    y_ema_backward = np.zeros_like(y)
    cur = y[-1]
    for i in range(len(y)-1, -1, -1):
        cur = smooth_coef * cur + (1 - smooth_coef) * y[i]
        y_ema_backward[i] = cur
    
    # 3. 取前向和后向的平均作为最终平滑值
    y_sym = (y_ema_forward + y_ema_backward) / 2.0
    
    # 4. 如果需要,将平滑后的曲线重采样到n_points个均匀点上
    if len(x) != n_points:
        x_smooth = np.linspace(x[0], x[-1], n_points)
        y_smooth = np.interp(x_smooth, x, y_sym)
    else:
        x_smooth, y_smooth = x, y_sym
    
    return x_smooth, y_smooth

def smooth_all_curves(common_steps, aligned_values, smooth_coef=0.8):
    """
    平滑所有对齐后的曲线。
    
    参数:
        common_steps: 统一的横坐标。
        aligned_values: 对齐后的值数组 (n_curves, n_points)。
        smooth_coef: EMA平滑系数。
    
    返回:
        smoothed_values: 平滑后的值数组 (n_curves, n_points)。
    """
    n_curves, n_points = aligned_values.shape
    smoothed_values = np.zeros_like(aligned_values)
    
    for i in range(n_curves):
        _, smoothed_curve = symmetric_ema(common_steps, aligned_values[i], smooth_coef=smooth_coef, n_points=n_points)
        smoothed_values[i] = smoothed_curve
    
    return smoothed_values

# 使用示例
smoothed_rewards = smooth_all_curves(common_steps, aligned_rewards, smooth_coef=0.85)

提示:smooth_coef 是一个关键超参数。值越接近1,曲线越平滑,但可能过度平滑,掩盖了真实的转折点。通常设置在0.6到0.95之间,需要根据你的数据噪声程度手动调整并可视化查看效果。

4. 计算统计量与绘制阴影

数据对齐并平滑后,我们终于可以计算中心趋势线和阴影边界了。这里我们使用matplotlib进行绘图,它提供了强大的自定义功能。

4.1 计算中心趋势与阴影边界

我们将实现一个函数,支持计算平均值/中位数,以及标准差/标准误差/分位数等多种阴影。

def compute_statistics(smoothed_data, center='mean', shade='std', confidence=0.95):
    """
    计算中心趋势线和阴影边界。
    
    参数:
        smoothed_data: 平滑后的数据,形状 (n_curves, n_points)。
        center: 中心趋势线统计量,'mean' 或 'median'。
        shade: 阴影统计量,'std', 'se', 'quantile', 'range', 'ci'。
        confidence: 当 shade='ci' 时使用的置信水平 (如0.95)。
    
    返回:
        center_line: 中心线,形状 (n_points, )。
        lower_bound: 阴影下边界,形状 (n_points, )。
        upper_bound: 阴影上边界,形状 (n_points, )。
    """
    n_curves, n_points = smoothed_data.shape
    
    # 计算中心趋势线
    if center == 'mean':
        center_line = np.nanmean(smoothed_data, axis=0)
    elif center == 'median':
        center_line = np.nanmedian(smoothed_data, axis=0)
    else:
        raise ValueError(f"不支持的 center 类型: {center}")
    
    # 计算阴影边界
    if shade == 'std':
        std_dev = np.nanstd(smoothed_data, axis=0, ddof=1) # 样本标准差
        lower_bound = center_line - std_dev
        upper_bound = center_line + std_dev
    elif shade == 'se':
        std_dev = np.nanstd(smoothed_data, axis=0, ddof=1)
        std_err = std_dev / np.sqrt(n_curves)
        lower_bound = center_line - std_err
        upper_bound = center_line + std_err
    elif shade == 'quantile':
        # 使用25%和75%分位数
        lower_quantile = np.nanpercentile(smoothed_data, 25, axis=0)
        upper_quantile = np.nanpercentile(smoothed_data, 75, axis=0)
        lower_bound = lower_quantile
        upper_bound = upper_quantile
    elif shade == 'range':
        lower_bound = np.nanmin(smoothed_data, axis=0)
        upper_bound = np.nanmax(smoothed_data, axis=0)
    elif shade == 'ci':
        from scipy import stats
        std_dev = np.nanstd(smoothed_data, axis=0, ddof=1)
        std_err = std_dev / np.sqrt(n_curves)
        # 使用t分布计算置信区间 (适用于小样本)
        t_val = stats.t.ppf((1 + confidence) / 2., n_curves - 1)
        ci_half_width = t_val * std_err
        lower_bound = center_line - ci_half_width
        upper_bound = center_line + ci_half_width
    else:
        raise ValueError(f"不支持的 shade 类型: {shade}")
    
    return center_line, lower_bound, upper_bound

4.2 使用Matplotlib绘制专业图表

现在,将以上所有步骤整合,并绘制出具有论文质量的图表。

import matplotlib.pyplot as plt
import matplotlib as mpl

def plot_shaded_curve(common_steps, smoothed_data, 
                      center='mean', shade='std',
                      label='Algorithm A',
                      color='steelblue',
                      line_style='-',
                      line_width=2,
                      shade_alpha=0.3,
                      ax=None):
    """
    绘制带阴影的折线图。
    
    参数:
        common_steps: 横坐标数组。
        smoothed_data: 平滑后的数据 (n_curves, n_points)。
        center, shade: 同 compute_statistics 函数。
        label: 图例标签。
        color: 线条和阴影的颜色。
        line_style: 线条样式。
        line_width: 线条宽度。
        shade_alpha: 阴影透明度。
        ax: matplotlib的Axes对象,如果为None则创建新的。
    
    返回:
        ax: 绘制所用的Axes对象。
    """
    if ax is None:
        fig, ax = plt.subplots(figsize=(8, 5))
    
    # 计算统计量
    center_line, lower_bound, upper_bound = compute_statistics(
        smoothed_data, center=center, shade=shade
    )
    
    # 绘制阴影区域
    ax.fill_between(common_steps, lower_bound, upper_bound, 
                    color=color, alpha=shade_alpha, label=f'{label} ({shade})')
    
    # 绘制中心趋势线
    ax.plot(common_steps, center_line, color=color, linestyle=line_style, 
            linewidth=line_width, label=label)
    
    # 美化图表
    ax.set_xlabel('Environment Steps', fontsize=12)
    ax.set_ylabel('Average Episode Reward', fontsize=12)
    ax.grid(True, linestyle='--', alpha=0.6)
    ax.legend(loc='best', fontsize=10)
    
    # 设置紧凑布局
    plt.tight_layout()
    
    return ax

# 完整示例:绘制并比较两种不同阴影
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 5))

# 子图1:使用平均值和标准差
plot_shaded_curve(common_steps, smoothed_rewards,
                  center='mean', shade='std',
                  label='DQN (Mean ± Std)',
                  color='#E24A33', # 橙色
                  ax=ax1)
ax1.set_title('Mean with Standard Deviation Shading', fontsize=14)

# 子图2:使用中位数和分位数区间
plot_shaded_curve(common_steps, smoothed_rewards,
                  center='median', shade='quantile',
                  label='DQN (Median ± IQR)',
                  color='#348ABD', # 蓝色
                  ax=ax2)
ax2.set_title('Median with Interquartile Range Shading', fontsize=14)

plt.show()

5. 进阶技巧与完整代码整合

掌握了基础绘制方法后,我们可以进一步优化,使其更贴近顶级论文的审美和实用性。

5.1 多算法对比与样式定制

在论文中,我们经常需要比较多个算法。下面的代码展示了如何在一个图中清晰地区分多条曲线。

def plot_multiple_algorithms(algorithm_data_dict, common_steps, 
                             center='mean', shade='std',
                             style_config=None):
    """
    在同一坐标系中绘制多个算法的阴影折线图。
    
    参数:
        algorithm_data_dict: 字典,键为算法名,值为平滑后的数据数组 (n_curves, n_points)。
        common_steps: 统一的横坐标。
        center, shade: 统计量类型。
        style_config: 可选字典,为每个算法指定颜色、线型等。
            例如: {'DQN': {'color': '#E24A33', 'linestyle': '-'},
                   'PPO': {'color': '#348ABD', 'linestyle': '--'}}
    """
    if style_config is None:
        style_config = {}
    
    # 预定义一组美观的颜色 (来自Tableau调色板)
    default_colors = ['#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', 
                      '#9467bd', '#8c564b', '#e377c2', '#7f7f7f', 
                      '#bcbd22', '#17becf']
    
    fig, ax = plt.subplots(figsize=(10, 6))
    
    for idx, (algo_name, smoothed_data) in enumerate(algorithm_data_dict.items()):
        # 获取样式配置,若无则使用默认
        config = style_config.get(algo_name, {})
        color = config.get('color', default_colors[idx % len(default_colors)])
        linestyle = config.get('linestyle', '-')
        linewidth = config.get('linewidth', 2.5)
        
        # 计算并绘制
        center_line, lower_bound, upper_bound = compute_statistics(
            smoothed_data, center=center, shade=shade
        )
        
        # 先画阴影,再画线,确保线在最上层
        ax.fill_between(common_steps, lower_bound, upper_bound,
                        color=color, alpha=0.25)
        ax.plot(common_steps, center_line, color=color, 
                linestyle=linestyle, linewidth=linewidth, 
                label=algo_name)
    
    # 高级美化
    ax.set_xlabel('Timesteps (Millions)', fontsize=13)
    ax.set_ylabel('Average Return', fontsize=13)
    ax.set_title('Comparison of RL Algorithms on Atari Pong', fontsize=15, pad=15)
    
    # 设置网格和边框
    ax.grid(True, which='both', linestyle=':', linewidth=0.5, alpha=0.7)
    for spine in ax.spines.values():
        spine.set_linewidth(1.2)
    
    # 图例放在外侧,避免遮挡曲线
    ax.legend(loc='upper left', bbox_to_anchor=(1.02, 1), borderaxespad=0., fontsize=11)
    
    # 使用科学计数法格式化横坐标(如果步数很大)
    from matplotlib.ticker import ScalarFormatter
    ax.xaxis.set_major_formatter(ScalarFormatter(useMathText=True))
    ax.ticklabel_format(axis='x', style='sci', scilimits=(6,6))
    
    plt.tight_layout(rect=[0, 0, 0.85, 1]) # 为外侧图例留出空间
    return fig, ax

# 假设我们有两个算法的数据
# smoothed_rewards_dqn 和 smoothed_rewards_ppo 是之前步骤处理好的数据
algo_dict = {
    'DQN (Ours)': smoothed_rewards_dqn, # 假设已定义
    'PPO (Baseline)': smoothed_rewards_ppo, # 假设已定义
}

style_conf = {
    'DQN (Ours)': {'color': '#D62728', 'linestyle': '-', 'linewidth': 3},
    'PPO (Baseline)': {'color': '#1F77B4', 'linestyle': '--', 'linewidth': 2.5},
}

fig, ax = plot_multiple_algorithms(algo_dict, common_steps, 
                                    center='mean', shade='std',
                                    style_config=style_conf)
# 保存为高分辨率图片,适合论文插入
fig.savefig('rl_algorithm_comparison.pdf', dpi=300, bbox_inches='tight')
fig.savefig('rl_algorithm_comparison.png', dpi=300, bbox_inches='tight')
plt.show()

5.2 完整流程封装与一键调用

最后,我们将所有步骤封装成一个简洁的类或函数,方便在多个项目中复用。

class RLPlotter:
    """
    深度强化学习实验绘图工具类。
    封装从数据加载到最终绘图的完整流程。
    """
    
    def __init__(self, log_dir_pattern, num_seeds=5):
        """
        初始化。
        
        参数:
            log_dir_pattern: 日志目录的模式字符串,例如 './logs/{}',
                             其中{}会被seed编号替换。
            num_seeds: 随机种子数量。
        """
        self.log_dir_pattern = log_dir_pattern
        self.num_seeds = num_seeds
        self.curves = None
        self.common_steps = None
        self.smoothed_data = None
        
    def load_and_process(self, n_points=500, smooth_coef=0.85):
        """加载数据并进行预处理(对齐、平滑)。"""
        all_curves = []
        for seed in range(1, self.num_seeds + 1):
            log_dir = self.log_dir_pattern.format(seed)
            # 这里需要根据你的实际日志结构实现数据加载
            # 假设有一个函数 load_single_seed_data
            data = self._load_single_seed_data(log_dir, seed)
            if data is not None:
                all_curves.append(data)
        
        self.curves = all_curves
        print(f"加载了 {len(self.curves)} 条有效曲线。")
        
        # 对齐
        self.common_steps, aligned_data = resample_curves(all_curves, n_points=n_points)
        # 平滑
        self.smoothed_data = smooth_all_curves(self.common_steps, aligned_data, 
                                                smooth_coef=smooth_coef)
        return self
    
    def _load_single_seed_data(self, log_dir, seed):
        """根据实际日志格式实现单个种子的数据加载。"""
        # 示例:读取CSV文件
        import pandas as pd
        import numpy as np
        try:
            # 假设你的评估奖励保存在 'eval_rewards.csv' 中
            file_path = f"{log_dir}/eval_rewards.csv"
            df = pd.read_csv(file_path)
            # 假设列名为 'step' 和 'reward'
            steps = df['step'].to_numpy()
            rewards = df['reward'].to_numpy()
            return np.column_stack((steps, rewards))
        except Exception as e:
            print(f"加载种子 {seed} 数据失败: {e}")
            return None
    
    def plot(self, center='mean', shade='std', **plot_kwargs):
        """绘制当前处理数据的阴影折线图。"""
        if self.smoothed_data is None:
            raise ValueError("请先调用 load_and_process() 方法处理数据。")
        
        fig, ax = plt.subplots(figsize=(8, 5))
        ax = plot_shaded_curve(self.common_steps, self.smoothed_data,
                               center=center, shade=shade,
                               ax=ax, **plot_kwargs)
        return fig, ax
    
    def get_processed_data(self):
        """获取处理后的数据,用于自定义绘图或其他分析。"""
        return self.common_steps, self.smoothed_data

# 使用示例
if __name__ == "__main__":
    # 初始化,假设日志按种子存放在不同子目录
    plotter = RLPlotter(log_dir_pattern='./experiments/seed_{}', num_seeds=5)
    
    # 加载、对齐、平滑数据
    plotter.load_and_process(n_points=400, smooth_coef=0.9)
    
    # 绘制标准差阴影图
    fig1, ax1 = plotter.plot(center='mean', shade='std',
                             label='Our Algorithm (Mean ± Std)',
                             color='royalblue')
    ax1.set_title('Training Performance with Standard Deviation', fontsize=14)
    plt.show()
    
    # 也可以获取处理后的数据,用于更复杂的绘图
    steps, data = plotter.get_processed_data()
    print(f"处理后的数据形状: {data.shape}")

从混乱的原始日志到一张能在论文中清晰传达信息的图表,关键在于理解每个处理步骤的意义并选择合适的统计量。我自己的经验是,在项目初期就建立这样的绘图流水线能节省大量后期调整时间。代码中的 smooth_coef 和阴影类型 (shade) 需要根据具体任务和数据特性进行微调,多尝试几次,找到最能平衡美观性与信息真实性的组合。最后,别忘了在图注中清晰地注明“阴影区域代表了标准差”或“曲线为中位数,阴影为25%-75%分位数区间”,这是学术严谨性的基本体现。

Logo

更多推荐