kdiagram.plot.uncertainty.plot_uncertainty_drift

kdiagram.plot.uncertainty.plot_uncertainty_drift(df, qlow_cols, qup_cols, dt_labels=None, theta_col=None, acov='default', base_radius=0.15, band_height=0.15, cmap='tab10', label='Year', alpha=0.85, figsize=(9, 9), title=None, show_grid=True, show_legend=True, mask_angle=True, savefig=None, dpi=300, ax=None)[source]

Polar plot visualizing temporal drift of uncertainty width.

This function creates a polar line plot showing how the width of the prediction interval (e.g., Q90 - Q10), representing model uncertainty, evolves over multiple time steps (e.g., years) across different locations. Each time step is depicted as a distinct concentric ring [1].

  • Angular Position (`theta`): Represents each location or data point. Currently derived from the DataFrame index, mapped linearly onto the angular range specified by acov. The optional theta_col parameter is intended for future use in ordering but is currently ignored for positioning.

  • Radial Rings (`r`): Each ring corresponds to a specific time step provided via qlow_cols/qup_cols. The position of the ring (distance from the center) indicates the time step (later times are further out). The radius of the line at a specific angle (location) on a given ring is determined by a base offset for that year plus a component proportional to the globally normalized interval width at that location and time. Thus, the ‘thickness’ or deviation of a ring from a perfect circle reflects the magnitude of uncertainty (interval width) relative to the maximum width observed across all locations and times.

  • Color: Each ring (time step) is assigned a unique color based on the specified cmap, aiding in distinguishing and tracking changes across time steps.

This visualization is particularly useful for:

  • Identifying locations where prediction uncertainty grows or shrinks significantly over the forecast horizon.

  • Monitoring the overall trend (drift) of uncertainty as forecasts extend further into the future.

  • Highlighting areas with consistently high or low uncertainty across all time steps.

  • Comparing the spatial patterns of uncertainty at different forecast lead times.

Parameters:
dfpd.DataFrame

Input DataFrame containing the quantile prediction columns. Decorators ensure it’s a valid, non-empty pandas DataFrame.

qlow_colslist of str

List of column names for the lower quantile bound (e.g., Q10) for consecutive time steps. Example: ['pred_2023_q10', 'pred_2024_q10', ...].

qup_colslist of str

List of column names for the upper quantile bound (e.g., Q90) for the same time steps as qlow_cols. Must be the same length. Example: ['pred_2023_q90', 'pred_2024_q90', ...].

dt_labelslist of str, optional

List of labels for each time step, used in the legend to identify the rings. Must match the length of qlow_cols. If None, generic labels like ‘Time_1’, ‘Time_2’, … are generated based on the label parameter. Default is None.

theta_colstr, optional

Intended column name for ordering points angularly. Note: This parameter is currently ignored in the positioning logic; angles are based on the DataFrame index. A warning is issued if provided. Default is None.

acov{‘default’, ‘half_circle’, ‘quarter_circle’,

‘eighth_circle’}, default=’default’

Specifies the angular coverage (span) of the plot: 'default' (360°), 'half_circle' (180°), 'quarter_circle' (90°), 'eighth_circle' (45°).

base_radiusfloat, default=0.15

Determines the spacing between the base circles of consecutive time step rings. The base radial offset for the ring representing time step i (0-indexed) is calculated as base_radius * (i+1). A larger value increases the separation between rings.

band_heightfloat, default=0.15

Scaling factor applied to the normalized interval width. This controls how much the radius deviates from the base circle for each ring, visually representing the uncertainty magnitude. Effectively, it’s the maximum radial ‘height’ allocated to represent uncertainty on each ring.

cmapstr, default=’tab10’

Name of the Matplotlib colormap used to assign distinct colors to the rings representing different time steps.

labelstr, default=’Year’

Base name used for generating default dt_labels if they are not provided (e.g., ‘Year_1’, ‘Year_2’, …). Note: This parameter is currently *not used for the legend title itself.*

alphafloat, default=0.85

Transparency level for the plotted lines (rings).

figsizetuple of (float, float), default=(9, 9)

Width and height of the figure in inches.

titlestr, optional

Title displayed above the polar plot. If None, a default title is used. Default is None.

show_gridbool, default=True

If True, display polar grid lines.

show_legendbool, default=True

If True, display a legend identifying the time step for each colored ring, using dt_labels.

mask_anglebool, default=True

If True, hide the angular tick labels (degrees). Recommended if the angular position is based on index.

savefigstr, optional

File path to save the plot image. If None, displays the plot interactively. Default is None.

Returns:
axmatplotlib.axes.Axes

The Matplotlib Axes object containing the polar line plot.

Raises:
AssertionError

If qlow_cols and qup_cols lists have different lengths.

ValueError

If specified quantile columns are not found in the DataFrame. If data in quantile columns is not numeric.

Parameters:

See also

plot_interval_consistency

Plot consistency of interval widths using scatter points.

numpy.linspace

Generate evenly spaced numbers.

matplotlib.pyplot.plot

Plot lines.

Notes

  • Interval widths (\(W = Q_{upper} - Q_{lower}\)) are calculated for each location and time step.

  • These widths are then globally normalized by dividing by the maximum width observed across all locations and all time steps. This ensures that the radial deviations are comparable across rings.

  • The radius for a point on ring i (0-indexed time step) is \(r = (\text{base}_{radius} \times (i+1)) + (\text{band}_{height} \times w_{normalized})\).

  • The angular coordinate theta is currently based on the DataFrame index, not influenced by theta_col.

  • Radial axis ticks and labels are hidden by default (set_yticks([])) as the radial dimension primarily separates the time steps.

Let \(\mathbf{L}_i\) and \(\mathbf{U}_i\) be the lower and upper quantile bound vectors (length \(N\), number of locations) for time step \(i\) (\(i = 0, \dots, M-1\)).

  1. Interval Width Calculation: For each time step \(i\), calculate the width vector:

    \(\mathbf{W}_i = \mathbf{U}_i - \mathbf{L}_i\).

  2. Global Normalization: Find the maximum width across all locations and time steps:

    \(W_{max} = \max_{i,j} (\mathbf{W}_i)_j\).

    Normalize each width vector \(\mathbf{W}_i\):

    (1)\[\mathbf{w}_i = \frac{\mathbf{W}_i}{W_{max}} \quad (\text{element-wise})\]

    If \(W_{max} = 0\), \(\mathbf{w}_i\) will be all zeros.

  3. Angular Coordinate (`theta`): Let \(S\) be the angular span and \(\theta_{min}\) the start angle from acov. For location index \(j\) (\(j=0, ..., N-1\)):

    (2)\[\theta_j = \left( \frac{j}{N} \times S \right) + \theta_{min}\]
  4. Radial Coordinate (`r`): For time step \(i\) and location \(j\):

    (3)\[\begin{split}r_{i,j} = (\text{base}_{radius} \times (i+1)) + \\ (\text{band}_{height} \times (\mathbf{w}_i)_j)\end{split}\]
  5. Plotting: For each time step \(i\), plot a line connecting points \((r_{i,j}, \theta_j)\) for \(j = 0, \dots, N-1\).

References

[1]

Kouadio, K. L., Liu, R., Loukou, K. G. H., Liu, J., & Liu, W. (2025). Analytics Framework for Interpreting Spatiotemporal Probabilistic Forecasts. International Journal of Forecasting. Manuscript submitted.

Examples

>>> import pandas as pd
>>> import numpy as np
>>> from kdiagram.plot.uncertainty import plot_uncertainty_drift

1. Random Example:

>>> np.random.seed(2)
>>> N_points = 100
>>> df_drift_rand = pd.DataFrame({'location_id': range(N_points)})
>>> years = range(2020, 2024)
>>> q10_drift_cols = []
>>> q90_drift_cols = []
>>> for i, year in enumerate(years):
...     q10_col = f'q10_{year}'
...     q90_col = f'q90_{year}'
...     base_val = np.random.rand(N_points) * 10
...     width = (np.random.rand(N_points) + 0.5) * (2 + i) # Increasing width
...     df_drift_rand[q10_col] = base_val - width / 2
...     df_drift_rand[q90_col] = base_val + width / 2
...     q10_drift_cols.append(q10_col)
...     q90_drift_cols.append(q90_col)
>>> ax_drift_rand = plot_uncertainty_drift(
...     df=df_drift_rand,
...     qlow_cols=q10_drift_cols,
...     qup_cols=q90_drift_cols,
...     dt_labels=[str(y) for y in years],
...     theta_col='location_id', # Ignored for positioning
...     acov='default',
...     base_radius=0.1,      # Smaller spacing
...     band_height=0.1,      # Smaller uncertainty scaling
...     cmap='viridis',
...     title='Random Uncertainty Drift Example',
...     mute_angle=False      # Show angle labels
... )
>>> # plt.show() called internally

2. Concrete Example (Subsidence Data - adapted):

>>> # Assume zhongshan_pred_2023_2026 is a loaded DataFrame like:
>>> # Create dummy data if it doesn't exist
>>> try:
...    zhongshan_pred_2023_2026
... except NameError:
...    print("Creating dummy subsidence data for example...")
...    N_sub = 150
...    zhongshan_pred_2023_2026 = pd.DataFrame({
...       'latitude': np.linspace(22.2, 22.8, N_sub),
...       **{f'subsidence_{yr}_q10': np.random.rand(N_sub)*(yr-2022)*2 + 1
...          for yr in range(2023, 2027)},
...       **{f'subsidence_{yr}_q90': np.random.rand(N_sub)*(yr-2022)*2 + 5
...          + np.linspace(0, (yr-2022)*3, N_sub) # Increasing width trend
...          for yr in range(2023, 2027)},
...     })
>>> qlow_sub_drift = [f'subsidence_{yr}_q10' for yr in range(2023, 2027)]
>>> qup_sub_drift = [f'subsidence_{yr}_q90' for yr in range(2023, 2027)]
>>> year_labels_sub = [str(yr) for yr in range(2023, 2027)]
>>> ax_sub_drift = plot_uncertainty_drift(
...     df=zhongshan_pred_2023_2026,
...     qlow_cols=qlow_sub_drift,
...     qup_cols=qup_sub_drift,
...     dt_labels=year_labels_sub,
...     theta_col='latitude',     # Ignored for positioning
...     acov='half_circle',     # Use 180 degrees
...     title='Uncertainty Drift Over Time (Zhongshan)',
...     cmap='tab10',
...     band_height=0.1,        # Controls visual width effect
...     base_radius=0.2,        # Controls spacing between years
...     show_legend=True,
...     mute_degree=True
... )
>>> # plt.show() called internally