# Visualization

> Publication-quality statistical visualization with mandatory best practices. Use when creating plots, charts, figures, or any data visualization. Enforces accessibility, uncertainty display, proper labeling, and statistical accuracy.

- **Type:** Skill
- **Install:** `agentstack add skill-sshtomar-claude-code-skills-social-science-visualization`
- **Verified:** Yes — security-reviewed for prompt injection and unsafe behavior
- **Seller:** [sshtomar](https://agentstack.voostack.com/s/sshtomar)
- **Installs:** 0
- **Category:** [Agent Skills](https://agentstack.voostack.com/c/agent-skills)
- **Latest version:** 0.1.0
- **License:** MIT
- **Upstream author:** [sshtomar](https://github.com/sshtomar)
- **Source:** https://github.com/sshtomar/claude-code-skills-social-science/tree/main/skills/visualization

## Install

```sh
agentstack add skill-sshtomar-claude-code-skills-social-science-visualization
```

Requires the [AgentStack CLI](https://agentstack.voostack.com/docs/cli). Works with Claude Code, Cursor, and any MCP-compatible agent.

## About

Visualization is not decoration—it is statistical communication. A well-designed plot reveals patterns that tables cannot, while a poor plot misleads more effectively than wrong numbers. Every visualization is an argument about data, and this skill enforces the standards that make those arguments honest, accessible, and reproducible.

The difference between amateur and professional visualization is not aesthetics but statistical integrity: showing uncertainty, avoiding perceptual tricks, ensuring accessibility, and documenting what you've done. This skill enforces visualization practices that meet publication standards while preventing common manipulations.

"The purpose of visualization is insight, not pictures" - Ben Shneiderman

FUNDAMENTAL RULES:
1. Every estimate needs uncertainty bands
2. Every axis needs units
3. Every color scheme needs accessibility testing
4. Every truncated axis needs justification
5. Every plot needs to be reproducible

  Complete Axis Labeling
  MUST label all axes with variable names AND units in parentheses
  Cleveland (1985) found unlabeled axes were the #1 source of misinterpretation in published figures
  Readers misinterpret scale, leading to order-of-magnitude errors in interpretation

  Uncertainty Visualization
  ALL point estimates MUST show confidence intervals or standard errors
  Cumming et al. (2007) demonstrate that showing uncertainty reduces overconfidence in conclusions by 40%
  False precision, overconfident claims, inability to distinguish signal from noise

  Accessibility Compliance
  MUST use colorblind-safe palettes, test with simulator, provide non-color redundancy
  8% of men and 0.5% of women have color vision deficiency (Neitz & Neitz, 2011)
  Excludes ~4% of readers, may violate journal/grant accessibility requirements

  Resolution and Format Standards
  Minimum 144 DPI for screens, 300 DPI for print, vector format for publication
  Journal standards require 300+ DPI; low resolution causes rejection at submission
  Desk rejection from journals, pixelated figures in presentations

  Honest Scaling
  Y-axis MUST start at zero for bar charts, ratios, and percentages unless explicitly justified
  Huff (1954) "How to Lie with Statistics" - truncated axes are the most common manipulation
  Exaggerated effects, misleading comparisons, ethical violations

  Return Figure Objects for Display
  MUST return figure objects (fig, plt.gcf(), or plt.gca()) as the last expression in the cell. NEVER use plt.show() - it prevents inline display in notebooks and breaks reproducibility
  Notebook environments (Jupyter, marimo) automatically display the last expression. plt.show() blocks execution, prevents inline display, and makes figures inaccessible for further manipulation. Returning figure objects enables notebook display, programmatic access, and proper figure management
  Figures don't display in notebooks, code execution blocked, figures cannot be saved or manipulated programmatically, breaks notebook workflow

When creating statistical visualizations:
1. Choose plot type based on data structure and question
2. Set up proper figure dimensions and resolution
3. Plot data with appropriate aesthetics
4. Add ALL required labels with units
5. Include uncertainty measures (CI, SE, prediction bands)
6. Test accessibility (colorblind simulation)
7. Add statistical annotations (p-values, R², n)
8. Save in multiple formats (PNG for viewing, PDF for publication)
9. Return figure object (fig, plt.gcf(), or plt.gca()) as last expression - NEVER use plt.show()
10. Document code for reproducibility

```python
# CRITICAL: Publication-quality visualization template

@app.cell
def create_publication_figure(df, x_var, y_var, group_var=None):
    """
    Every visualization must meet publication standards.
    This template enforces labeling, uncertainty, accessibility, and
    reproducibility requirements that prevent common mistakes.
    """
    import matplotlib.pyplot as plt
    import numpy as np
    import pandas as pd
    import seaborn as sns
    import os
    from matplotlib import cm

    # MANDATORY: Set publication-quality defaults
    plt.rcParams.update({
        'font.size': 11,
        'font.family': 'sans-serif',
        'axes.labelsize': 12,
        'axes.titlesize': 13,
        'xtick.labelsize': 10,
        'ytick.labelsize': 10,
        'legend.fontsize': 10,
        'figure.titlesize': 14,
        'axes.linewidth': 1.5,
        'axes.grid': True,
        'grid.alpha': 0.3,
    })

    # MANDATORY: Colorblind-safe palette
    # Source: Wong, B. (2011) Nature Methods 8, 441
    colorblind_colors = [
        '#0173B2',  # Blue
        '#DE8F05',  # Orange
        '#029E73',  # Green
        '#CC78BC',  # Light purple
        '#ECE133',  # Yellow
        '#56B4E9',  # Light blue
        '#F0E442',  # Light yellow
    ]

    # Create figure with specific size for journal column width
    # Single column: 3.5", double column: 7"
    fig, ax = plt.subplots(figsize=(7, 5), dpi=144)

    # Get data statistics for annotations
    n_obs = len(df)
    x_mean = df[x_var].mean()
    y_mean = df[y_var].mean()

    if group_var is None:
        # Single group with confidence band
        ax.scatter(df[x_var], df[y_var], alpha=0.6, s=50,
                  color=colorblind_colors[0], edgecolor='black', linewidth=0.5)

        # Add regression line with confidence interval
        from scipy import stats
        slope, intercept, r_value, p_value, std_err = stats.linregress(df[x_var], df[y_var])

        x_line = np.linspace(df[x_var].min(), df[x_var].max(), 100)
        y_line = slope * x_line + intercept

        # Calculate confidence interval for regression line
        from scipy.stats import t
        predict_se = std_err * np.sqrt(1/n_obs + (x_line - x_mean)**2 / ((df[x_var] - x_mean)**2).sum())
        margin = t.ppf(0.975, n_obs - 2) * predict_se

        ax.plot(x_line, y_line, color=colorblind_colors[0], linewidth=2,
               label=f'Fit (R² = {r_value**2:.3f})')
        ax.fill_between(x_line, y_line - margin, y_line + margin,
                       alpha=0.2, color=colorblind_colors[0], label='95% CI')

        # Add statistical annotation
        ax.text(0.05, 0.95, f'n = {n_obs}\np = {p_value:.3f}',
               transform=ax.transAxes, verticalalignment='top',
               bbox=dict(boxstyle='round', facecolor='wheat', alpha=0.5))

    else:
        # Multiple groups with distinct colors and markers
        markers = ['o', 's', '^', 'D', 'v', '']
        groups = df[group_var].unique()

        for i, group in enumerate(groups):
            subset = df[df[group_var] == group]
            color = colorblind_colors[i % len(colorblind_colors)]
            marker = markers[i % len(markers)]

            # Plot with confidence intervals
            x_vals = subset.groupby(x_var)[y_var].mean()
            x_sems = subset.groupby(x_var)[y_var].sem()

            ax.errorbar(x_vals.index, x_vals.values, yerr=1.96*x_sems.values,
                       marker=marker, markersize=8, linewidth=2, capsize=5,
                       color=color, label=f'{group} (n={len(subset)})',
                       alpha=0.8)

    # MANDATORY: Complete axis labeling with units
    # Extract units from variable names if formatted as "variable (unit)"
    x_label = x_var if '(' in x_var else f"{x_var} (units)"
    y_label = y_var if '(' in y_var else f"{y_var} (units)"

    ax.set_xlabel(x_label, fontweight='bold')
    ax.set_ylabel(y_label, fontweight='bold')

    # MANDATORY: Informative title
    if group_var:
        ax.set_title(f'{y_var} vs {x_var} by {group_var}\n(Mean ± 95% CI)',
                    fontweight='bold', pad=20)
    else:
        ax.set_title(f'{y_var} vs {x_var}\n(with 95% Confidence Band)',
                    fontweight='bold', pad=20)

    # MANDATORY: Grid for readability
    ax.grid(True, alpha=0.3, linestyle='--')

    # MANDATORY: Legend with sample sizes
    if group_var or True:  # Always show legend for clarity
        ax.legend(loc='best', frameon=True, fancybox=True, shadow=True)

    # Check if y-axis should start at zero (for ratios, percentages, counts)
    if any(keyword in y_var.lower() for keyword in ['percent', 'ratio', 'proportion', 'count']):
        y_min, y_max = ax.get_ylim()
        if y_min > 0:
            ax.set_ylim(bottom=0, top=y_max * 1.1)
            ax.annotate('Note: Y-axis starts at zero', xy=(0.5, -0.15),
                       xycoords='axes fraction', ha='center', fontsize=9, style='italic')

    # Add data source and timestamp for reproducibility
    fig.text(0.99, 0.01, f'Data: {n_obs} observations | Generated: {pd.Timestamp.now().strftime("%Y-%m-%d")}',
            ha='right', fontsize=8, style='italic', alpha=0.7)

    # Tight layout to prevent label cutoff
    plt.tight_layout()

    # MANDATORY: Save in multiple formats
    os.makedirs("./images", exist_ok=True)

    # Screen viewing (PNG at 144 DPI)
    plt.savefig("./images/figure_screen.png", dpi=144, bbox_inches="tight")

    # Publication (PDF vector format)
    plt.savefig("./images/figure_publication.pdf", bbox_inches="tight")

    # High-res for presentations (PNG at 300 DPI)
    plt.savefig("./images/figure_highres.png", dpi=300, bbox_inches="tight")

    print("Figure saved in three formats:")
    print("  ./images/figure_screen.png (144 DPI for screens)")
    print("  ./images/figure_publication.pdf (vector for journals)")
    print("  ./images/figure_highres.png (300 DPI for presentations)")

    # CRITICAL: Return figure for inline display in notebook
    # Do NOT call plt.show() - it prevents inline display and blocks execution
    # Do NOT call plt.close() - this would prevent display
    # MUST return figure object (fig, plt.gcf(), or plt.gca()) as last expression
    return fig,
```

Visualizing treatment effects with proper uncertainty bands

```python
@app.cell
def plot_treatment_effects():
    #Treatment effects must show uncertainty to enable
    # proper inference. This example shows the gold standard for
    # presenting experimental results with multiple treatments.

    import matplotlib.pyplot as plt
    import numpy as np
    import pandas as pd
    import os

    # Simulated treatment effects from an experiment
    treatments = ['Control', 'Treatment A', 'Treatment B', 'Treatment C']
    effects = [0, 2.3, 3.7, 1.8]
    std_errors = [0.5, 0.6, 0.8, 0.7]
    sample_sizes = [102, 98, 95, 101]
    p_values = [1.000, 0.001, 0.0001, 0.042]

    # MANDATORY: Colorblind-safe palette
    colors = ['#808080', '#0173B2', '#029E73', '#DE8F05']

    fig, ax = plt.subplots(figsize=(10, 6), dpi=144)

    # Calculate 95% confidence intervals
    ci_lower = [e - 1.96*se for e, se in zip(effects, std_errors)]
    ci_upper = [e + 1.96*se for e, se in zip(effects, std_errors)]

    # Create coefficient plot (forest plot style)
    y_positions = range(len(treatments))

    for i, (treatment, effect, lower, upper, n, p, color) in enumerate(
        zip(treatments, effects, ci_lower, ci_upper, sample_sizes, p_values, colors)
    ):
        # Plot point estimate
        ax.scatter(effect, i, s=150, color=color, zorder=3,
                  edgecolors='black', linewidth=1.5)

        # Plot confidence interval
        ax.plot([lower, upper], [i, i], linewidth=3, color=color, alpha=0.7)

        # Add caps to CI
        cap_width = 0.05
        ax.plot([lower, lower], [i-cap_width, i+cap_width], linewidth=2, color=color)
        ax.plot([upper, upper], [i-cap_width, i+cap_width], linewidth=2, color=color)

        # Add text annotations with sample size and p-value
        significance = '***' if p 

Forest plots are the gold standard for showing treatment effects. Always include: point estimates, confidence intervals, sample sizes, p-values, and a reference line at zero.

Time series with intervention points and uncertainty bands

```python
@app.cell
def plot_time_series_with_intervention():
    #Time series plots must clearly show when interventions
    # occurred and quantify uncertainty around trends. This example shows
    # best practices for interrupted time series visualization.

    import matplotlib.pyplot as plt
    import numpy as np
    import pandas as pd
    from scipy import stats
    import os

    # Generate example time series data
    np.random.seed(42)
    dates = pd.date_range('2020-01-01', '2023-12-31', freq='M')
    n_points = len(dates)

    # Pre-intervention trend
    pre_intervention = 36  # Month 36 is intervention
    trend_pre = 100 + 2 * np.arange(pre_intervention)
    noise_pre = np.random.normal(0, 10, pre_intervention)

    # Post-intervention (level shift + trend change)
    level_shift = 20
    trend_change = -1.5
    trend_post = (100 + 2 * pre_intervention + level_shift +
                 trend_change * np.arange(n_points - pre_intervention))
    noise_post = np.random.normal(0, 12, n_points - pre_intervention)

    # Combine
    values = np.concatenate([trend_pre + noise_pre, trend_post + noise_post])

    df = pd.DataFrame({
        'date': dates,
        'value': values,
        'period': ['Pre' if i 

For interrupted time series: Always mark the intervention clearly, show pre/post trends separately, quantify level and slope changes, and include uncertainty bands.

Multi-panel publication-ready figure with shared aesthetics

```python
@app.cell
def create_publication_panel_figure():
    #Multi-panel figures are standard in publications.
    # This example shows how to create a complex figure with multiple
    # related plots that share formatting and meet journal standards.

    import matplotlib.pyplot as plt
    import matplotlib.gridspec as gridspec
    import numpy as np
    import pandas as pd
    import seaborn as sns
    from scipy import stats
    import os

    # Generate example data
    np.random.seed(123)
    n = 200
    df = pd.DataFrame({
        'x': np.random.normal(50, 15, n),
        'y': np.random.normal(100, 20, n),
        'group': np.random.choice(['Control', 'Treatment'], n),
        'covariate': np.random.uniform(0, 100, n)
    })

    # Add treatment effect
    treatment_mask = df['group'] == 'Treatment'
    df.loc[treatment_mask, 'y'] += 10 + 0.3 * df.loc[treatment_mask, 'covariate']

    # MANDATORY: Set up publication-quality figure
    # For Nature/Science: width = 180mm (7.09 inches) for full page
    fig = plt.figure(figsize=(7, 8), dpi=300)

    # Use GridSpec for complex layout
    gs = gridspec.GridSpec(3, 2, height_ratios=[1, 1, 0.8],
                          width_ratios=[1, 1], hspace=0.3, wspace=0.3)

    # Colorblind-safe colors
    colors = {'Control': '#0173B2', 'Treatment': '#DE8F05'}

    # PANEL A: Distribution comparison
    ax_a = fig.add_subplot(gs[0, :])

    for group in ['Control', 'Treatment']:
        subset = df[df['group'] == group]['y']

        # Histogram with KDE
        counts, bins, _ = ax_a.hist(subset, bins=20, alpha=0.5,
                                    label=group, color=colors[group],
                                    edgecolor='black', linewidth=0.5)

        # Add KDE
        kde = stats.gaussian_kde(subset)
        x_range = np.linspace(subset.min(), subset.max(), 100)
        kde_values = kde(x_range) * len(subset) * (bins[1] - bins[0])
        ax_a.plot(x_range, kde_values, color=colors[group],
                 linewidth=2, alpha=0.8)

        # Add mean line
        mean_val = subset.mean()
        ax_a.axvline(mean_val, color=colors[group], linestyle='--',
                    linewidth=2, alpha=0.7)

        # Add text with stats
        ax_a.text(mean_val, ax_a.get_ylim()[1]*0.9,
                 f'μ={mean_val:.1f}',
                 ha='center', fontsize=8, color=colors[group])

    ax_a.set_xlabel('Outcome (units)', fontweight='bold')
    ax_a.set_ylabel('Frequency', fontweight='bold')
    ax_a.set_title('A. Distribution by Group', fontweight='bold', loc='left')
    ax_a.legend(loc='upper right')
    ax_a.grid(True, alpha=0.3)

…

## Source & license

This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.

- **Author:** [sshtomar](https://github.com/sshtomar)
- **Source:** [sshtomar/claude-code-skills-social-science](https://github.com/sshtomar/claude-code-skills-social-science)
- **License:** MIT

Install and usage instructions live in the source repository linked above.

## Pricing

- **Free** — Free

## Security capabilities

Automated source analysis of v0.1.0 — what this tool can access:

- **Network access:** no
- **Filesystem access:** no
- **Shell / process execution:** no
- **Environment & secrets:** no
- **Dynamic code execution:** no

*"Yes" means the capability is present in the source — more access means more to trust, not that it is unsafe.*


## Versions

- **0.1.0** — security scan: passed — Imported from the upstream source.

## Links

- Listing page: https://agentstack.voostack.com/l/skill-sshtomar-claude-code-skills-social-science-visualization
- Seller: https://agentstack.voostack.com/s/sshtomar
- Browse the marketplace: https://agentstack.voostack.com/browse

---
Listed on AgentStack — the marketplace for AI agent skills and MCP servers. Every listing is security-reviewed. Creators keep 70%.
