Transform basic charts into stunning, publication-ready visualizations
By the end of this lesson, you will be able to:
ggplot and seaborn) and build reusable custom style sheets with rcParamsLinearSegmentedColormap⏱️ Estimated Time: 45–60 minutes
🎯 Project: Take a plain Matplotlib chart and restyle it into a polished, publication-quality figure with a custom color palette, annotations, and a reusable style sheet.
Imagine you're a designer with unlimited creative control over every pixel of your visualization. Colors that tell stories, fonts that convey authority, annotations that guide the eye - these details transform good charts into great ones!
In professional data science, the difference between a plot that gets ignored and one that drives decisions often comes down to customization. A well-styled chart isn't just prettier - it's clearer, more persuasive, and more memorable. Let's master the tools that turn data into visual masterpieces!
Experiment with different customization options in real-time:
Use colors effectively to enhance understanding and appeal
# Basic color options
import matplotlib.pyplot as plt
import numpy as np
x = np.linspace(0, 10, 100)
y1 = np.sin(x)
y2 = np.cos(x)
# Named colors
plt.plot(x, y1, color='crimson', label='Sin')
plt.plot(x, y2, color='forestgreen', label='Cos')
# Hex colors
plt.plot(x, y1, color='#FF5733', label='Custom')
# RGB tuples (values 0-1)
plt.plot(x, y2, color=(0.1, 0.2, 0.5), label='RGB')
# With transparency (alpha)
plt.plot(x, y1, color='blue', alpha=0.5)
plt.fill_between(x, 0, y1, color='blue', alpha=0.3)
# Colormaps for multiple lines
colors = plt.cm.viridis(np.linspace(0, 1, 5))
for i, color in enumerate(colors):
plt.plot(x, np.sin(x + i), color=color)
# Custom color palette
from matplotlib.colors import ListedColormap
custom_colors = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#FFA07A']
cmap = ListedColormap(custom_colors)
# Categorical colors
from matplotlib.colors import Normalize
from matplotlib.cm import ScalarMappable
fig, ax = plt.subplots()
categories = np.random.choice([0, 1, 2, 3], 100)
colors = [custom_colors[cat] for cat in categories]
scatter = ax.scatter(np.random.randn(100),
np.random.randn(100),
c=colors, s=100, alpha=0.6)
plt.show()
Control every aspect of text appearance
# Font customization
import matplotlib.pyplot as plt
import matplotlib.font_manager as fm
fig, ax = plt.subplots(figsize=(10, 6))
# Title with custom font
ax.set_title('Custom Typography Example',
fontsize=20,
fontweight='bold',
fontfamily='serif',
color='#2E86AB',
pad=20) # Padding from axes
# Axis labels with different styles
ax.set_xlabel('X Axis Label',
fontsize=14,
fontstyle='italic',
fontweight='normal')
ax.set_ylabel('Y Axis Label',
fontsize=14,
rotation=90,
labelpad=10)
# Custom tick labels
ax.tick_params(axis='x',
labelsize=12,
labelcolor='gray',
labelrotation=45)
ax.tick_params(axis='y',
labelsize=12,
labelcolor='gray')
# Text annotations with boxes
ax.text(0.5, 0.5, 'Centered Text',
transform=ax.transAxes, # Use axes coordinates
fontsize=16,
ha='center', va='center',
bbox=dict(boxstyle='round,pad=0.5',
facecolor='yellow',
edgecolor='black',
alpha=0.8))
# Mathematical expressions (LaTeX)
ax.text(0.1, 0.9, r'$\sum_{i=1}^{n} x_i = \frac{1}{2}\alpha$',
transform=ax.transAxes,
fontsize=14)
# Using system fonts
available_fonts = [f.name for f in fm.fontManager.ttflist]
# print(available_fonts) # List all available fonts
# Font properties
from matplotlib import font_manager
prop = font_manager.FontProperties(fname='/path/to/font.ttf')
ax.text(0.5, 0.2, 'Custom Font File', fontproperties=prop)
# Global font settings
plt.rcParams['font.family'] = 'sans-serif'
plt.rcParams['font.sans-serif'] = ['Arial', 'DejaVu Sans']
plt.rcParams['font.size'] = 12
plt.show()
Guide attention with annotations, arrows, and callouts
# Advanced annotations
import matplotlib.pyplot as plt
import numpy as np
fig, ax = plt.subplots(figsize=(10, 6))
x = np.linspace(0, 10, 100)
y = np.sin(x) * np.exp(-x/10)
ax.plot(x, y, 'b-', linewidth=2)
# Simple annotation with arrow
max_idx = np.argmax(y)
ax.annotate('Peak Value',
xy=(x[max_idx], y[max_idx]), # Point to annotate
xytext=(x[max_idx]+2, y[max_idx]+0.2), # Text location
arrowprops=dict(arrowstyle='->',
connectionstyle='arc3,rad=0.3',
color='red',
lw=2))
# Fancy arrow styles
arrow_styles = ['->', '-[', '|-|', '-|>', '<->', 'fancy']
for i, style in enumerate(arrow_styles[:4]):
ax.annotate(f'Style: {style}',
xy=(i*2, 0.5), xytext=(i*2, 0.7),
arrowprops=dict(arrowstyle=style))
# Annotation with custom bbox
ax.annotate('Important Region',
xy=(5, 0), xytext=(6, -0.3),
bbox=dict(boxstyle="round,pad=0.3",
facecolor='yellow',
edgecolor='red',
linewidth=2),
arrowprops=dict(arrowstyle='->',
connectionstyle='angle3,angleA=90,angleB=0',
color='red'))
# Highlighting regions
ax.axvspan(3, 5, alpha=0.3, color='green',
label='Region of Interest')
ax.axhline(y=0, color='gray', linestyle='--', alpha=0.5)
ax.axvline(x=7, color='gray', linestyle='--', alpha=0.5)
# Adding shapes
from matplotlib.patches import Circle, Rectangle, Ellipse, Polygon
# Circle
circle = Circle((2, 0.5), 0.2, color='blue', alpha=0.3)
ax.add_patch(circle)
# Rectangle
rect = Rectangle((6, -0.2), 1, 0.4,
facecolor='red', alpha=0.3,
edgecolor='black', linewidth=2)
ax.add_patch(rect)
# Ellipse
ellipse = Ellipse((8, 0.3), width=1, height=0.3,
angle=45, facecolor='green', alpha=0.3)
ax.add_patch(ellipse)
# Custom polygon
polygon = Polygon([[1, 0], [1.5, 0.3], [2, 0], [1.5, -0.2]],
facecolor='orange', alpha=0.5)
ax.add_patch(polygon)
# Connection patches between points
from matplotlib.patches import ConnectionPatch
con = ConnectionPatch((1, 0.5), (3, 0.3), "data", "data",
arrowstyle="-|>", shrinkA=5, shrinkB=5,
mutation_scale=20, fc="red", ec="red", lw=2)
ax.add_artist(con)
plt.legend()
plt.show()
Comprehensive guide to line styles and markers
# Line and marker combinations
import matplotlib.pyplot as plt
import numpy as np
x = np.linspace(0, 10, 20)
# Line styles
linestyles = ['-', '--', '-.', ':', 'None']
markers = ['o', 's', '^', 'd', 'v', '<', '>',
'p', '*', 'h', 'H', '+', 'x', 'D']
fig, axes = plt.subplots(2, 1, figsize=(12, 8))
# Line style showcase
ax1 = axes[0]
for i, ls in enumerate(linestyles[:4]):
y = np.sin(x + i*0.5)
ax1.plot(x, y, linestyle=ls, linewidth=2,
label=f'Linestyle: {ls}')
ax1.legend()
ax1.set_title('Line Styles')
# Marker showcase
ax2 = axes[1]
for i, marker in enumerate(markers[:5]):
y = np.cos(x + i*0.5) * 0.8
ax2.plot(x[::2], y[::2], marker=marker,
markersize=10, linestyle='-',
linewidth=1, alpha=0.7,
markerfacecolor='white',
markeredgecolor='auto',
markeredgewidth=2,
label=f'Marker: {marker}')
ax2.legend()
ax2.set_title('Marker Styles')
# Advanced marker customization
fig2, ax = plt.subplots(figsize=(10, 6))
# Custom marker properties
ax.plot(x, np.sin(x), 'o-',
markersize=12,
markerfacecolor='yellow',
markeredgecolor='red',
markeredgewidth=2,
markevery=3, # Show marker every 3rd point
label='Custom markers')
# Different markers for different data points
y = np.cos(x)
colors = plt.cm.rainbow(np.linspace(0, 1, len(x)))
for i, (xi, yi, c) in enumerate(zip(x, y, colors)):
marker = markers[i % len(markers)]
ax.plot(xi, yi, marker=marker, markersize=10,
color=c, markeredgecolor='black')
plt.tight_layout()
plt.show()
Apply consistent themes across all your plots
# Using and creating custom styles
import matplotlib.pyplot as plt
import matplotlib as mpl
import numpy as np
# Built-in styles
print(plt.style.available) # List all available styles
# Apply a style
plt.style.use('seaborn-v0_8-darkgrid')
# or temporarily
with plt.style.context('ggplot'):
plt.plot([1, 2, 3], [1, 4, 9])
plt.show()
# Create custom style
custom_style = {
# Figure
'figure.figsize': (10, 6),
'figure.facecolor': 'white',
'figure.edgecolor': 'none',
'figure.dpi': 100,
# Axes
'axes.facecolor': '#f8f9fa',
'axes.edgecolor': '#dee2e6',
'axes.linewidth': 1,
'axes.grid': True,
'axes.titlesize': 16,
'axes.titleweight': 'bold',
'axes.labelsize': 12,
'axes.labelweight': 'normal',
'axes.spines.top': False,
'axes.spines.right': False,
# Grid
'grid.color': '#e9ecef',
'grid.linestyle': '-',
'grid.linewidth': 0.8,
'grid.alpha': 0.5,
# Lines
'lines.linewidth': 2,
'lines.markersize': 8,
# Colors (color cycle)
'axes.prop_cycle': mpl.cycler(color=[
'#2E86AB', '#A23B72', '#F18F01',
'#C73E1D', '#6C464E', '#395B50'
]),
# Font
'font.family': 'sans-serif',
'font.sans-serif': ['Arial', 'DejaVu Sans'],
'font.size': 11,
# Legend
'legend.frameon': True,
'legend.framealpha': 0.9,
'legend.facecolor': 'white',
'legend.edgecolor': '#dee2e6',
'legend.fontsize': 10,
# Ticks
'xtick.labelsize': 10,
'ytick.labelsize': 10,
'xtick.color': '#495057',
'ytick.color': '#495057',
}
# Apply custom style
for key, value in custom_style.items():
plt.rcParams[key] = value
# Reset to defaults
mpl.rcdefaults()
# Save style to file
import json
# Save as JSON
with open('my_style.json', 'w') as f:
json.dump(custom_style, f)
# Create a .mplstyle file
with open('my_style.mplstyle', 'w') as f:
for key, value in custom_style.items():
f.write(f'{key}: {value}\n')
# Use custom style file
plt.style.use('my_style.mplstyle')
# Context manager for temporary style changes
with plt.rc_context(custom_style):
fig, ax = plt.subplots()
ax.plot([1, 2, 3], [1, 4, 9])
plt.show()
# Dark theme example
dark_theme = {
'figure.facecolor': '#1a1a1a',
'axes.facecolor': '#2b2b2b',
'axes.edgecolor': '#555',
'axes.labelcolor': '#ccc',
'text.color': '#ccc',
'xtick.color': '#ccc',
'ytick.color': '#ccc',
'grid.color': '#444',
'legend.facecolor': '#2b2b2b',
'legend.edgecolor': '#555',
}
Choose the perfect colormap for your data
# Colormap selection and customization
import matplotlib.pyplot as plt
import matplotlib.cm as cm
import numpy as np
# Generate sample data
x = np.linspace(-3, 3, 100)
y = np.linspace(-3, 3, 100)
X, Y = np.meshgrid(x, y)
Z = np.sin(np.sqrt(X**2 + Y**2))
fig, axes = plt.subplots(2, 3, figsize=(12, 8))
# Different colormap categories
cmaps = {
'Sequential': 'viridis',
'Diverging': 'RdBu_r',
'Qualitative': 'Set3',
'Cyclic': 'twilight',
'Sequential2': 'plasma',
'Perceptual': 'cividis'
}
for ax, (name, cmap) in zip(axes.flat, cmaps.items()):
im = ax.imshow(Z, cmap=cmap, extent=[-3, 3, -3, 3])
ax.set_title(f'{name}: {cmap}')
plt.colorbar(im, ax=ax, fraction=0.046)
# Custom colormap
from matplotlib.colors import LinearSegmentedColormap
# Define colors
colors = ['#FF6B6B', '#FFE66D', '#4ECDC4', '#45B7D1']
n_bins = 100
cmap = LinearSegmentedColormap.from_list('custom', colors, N=n_bins)
# Discrete colormap
discrete_cmap = LinearSegmentedColormap.from_list(
'discrete', colors, N=len(colors))
# Colormap with specific levels
levels = [-1, -0.5, 0, 0.5, 1]
from matplotlib.colors import BoundaryNorm
norm = BoundaryNorm(levels, ncolors=256, extend='both')
# Colormap for categorical data
from matplotlib.colors import ListedColormap
cat_colors = ['#E74C3C', '#3498DB', '#2ECC71', '#F39C12']
cat_cmap = ListedColormap(cat_colors)
# Accessing specific colors from colormap
viridis = cm.get_cmap('viridis')
colors_from_map = viridis(np.linspace(0, 1, 10))
# Reversed colormap
reversed_cmap = cm.get_cmap('viridis_r') # Add _r for reversed
# Truncated colormap
def truncate_colormap(cmap, minval=0.0, maxval=1.0, n=100):
new_cmap = LinearSegmentedColormap.from_list(
f'trunc({cmap.name},{minval:.2f},{maxval:.2f})',
cmap(np.linspace(minval, maxval, n)))
return new_cmap
trunc_viridis = truncate_colormap(viridis, 0.2, 0.8)
plt.tight_layout()
plt.show()
Create a fully customized, publication-quality figure combining all techniques:
import matplotlib.pyplot as plt
import matplotlib.gridspec as gridspec
import numpy as np
from matplotlib.patches import Rectangle, FancyBboxPatch
from matplotlib.collections import PatchCollection
import matplotlib.patches as mpatches
# Set up the figure with golden ratio
golden_ratio = (1 + np.sqrt(5)) / 2
fig_width = 12
fig_height = fig_width / golden_ratio
# Create figure with custom style
plt.style.use('seaborn-v0_8-whitegrid')
fig = plt.figure(figsize=(fig_width, fig_height))
# Custom color palette (colorblind-friendly)
colors = {
'primary': '#0173B2',
'secondary': '#DE8F05',
'tertiary': '#029E73',
'quaternary': '#CC78BC',
'highlight': '#ECE133',
'dark': '#2E3440',
'light': '#ECEFF4'
}
# Create GridSpec for complex layout
gs = gridspec.GridSpec(3, 3,
width_ratios=[1.5, 1, 1],
height_ratios=[1, 1, 0.5],
wspace=0.3, hspace=0.4)
# Generate sample scientific data
np.random.seed(42)
time = np.linspace(0, 10, 1000)
signal1 = np.sin(2 * np.pi * time) * np.exp(-time/5) + np.random.normal(0, 0.05, 1000)
signal2 = np.cos(2 * np.pi * time) * np.exp(-time/7) + np.random.normal(0, 0.05, 1000)
# Main plot - Time series with confidence bands
ax1 = fig.add_subplot(gs[:2, 0])
# Calculate rolling statistics
window = 50
mean1 = pd.Series(signal1).rolling(window=window).mean()
std1 = pd.Series(signal1).rolling(window=window).std()
# Plot with confidence bands
ax1.fill_between(time, mean1 - 2*std1, mean1 + 2*std1,
color=colors['primary'], alpha=0.2, label='95% CI')
ax1.plot(time, signal1, color=colors['primary'], alpha=0.3, linewidth=0.5)
ax1.plot(time, mean1, color=colors['primary'], linewidth=2.5, label='Signal A')
ax1.plot(time, signal2, color=colors['secondary'], linewidth=2.5,
linestyle='--', label='Signal B')
# Annotations
peak_idx = np.argmax(mean1.dropna())
ax1.annotate('Peak Response',
xy=(time[peak_idx], mean1.iloc[peak_idx]),
xytext=(time[peak_idx] + 2, mean1.iloc[peak_idx] + 0.3),
arrowprops=dict(arrowstyle='-|>',
connectionstyle='arc3,rad=0.3',
color=colors['dark'],
lw=1.5),
fontsize=10,
bbox=dict(boxstyle="round,pad=0.3",
facecolor=colors['highlight'],
edgecolor=colors['dark'],
alpha=0.8))
# Highlight regions
ax1.axvspan(2, 4, alpha=0.1, color=colors['tertiary'], label='Critical Period')
# Styling
ax1.set_title('Temporal Evolution of System Response',
fontsize=14, fontweight='bold', pad=15)
ax1.set_xlabel('Time (s)', fontsize=11)
ax1.set_ylabel('Amplitude (a.u.)', fontsize=11)
ax1.grid(True, alpha=0.3, linestyle='--')
ax1.legend(loc='upper right', frameon=True, fancybox=True,
shadow=True, fontsize=9)
# Spectral analysis
ax2 = fig.add_subplot(gs[0, 1])
freqs = np.fft.fftfreq(len(signal1), d=time[1]-time[0])
fft1 = np.abs(np.fft.fft(signal1))
mask = freqs > 0
ax2.semilogy(freqs[mask], fft1[mask], color=colors['primary'],
linewidth=1.5, label='Signal A')
ax2.fill_between(freqs[mask], 1e-3, fft1[mask],
color=colors['primary'], alpha=0.3)
ax2.set_title('Frequency Spectrum', fontsize=12, fontweight='bold')
ax2.set_xlabel('Frequency (Hz)', fontsize=10)
ax2.set_ylabel('Power', fontsize=10)
ax2.grid(True, which='both', alpha=0.3)
ax2.set_xlim(0, 5)
# Phase space plot
ax3 = fig.add_subplot(gs[0, 2])
ax3.scatter(signal1[:-1], signal1[1:], c=time[:-1],
cmap='viridis', s=1, alpha=0.5)
ax3.set_title('Phase Space', fontsize=12, fontweight='bold')
ax3.set_xlabel('x(t)', fontsize=10)
ax3.set_ylabel('x(t+1)', fontsize=10)
ax3.grid(True, alpha=0.3)
# Distribution plot
ax4 = fig.add_subplot(gs[1, 1])
counts, bins, patches = ax4.hist(signal1, bins=50,
density=True, alpha=0.7,
color=colors['primary'],
edgecolor='black', linewidth=0.5)
# Fit and plot normal distribution
from scipy import stats
mu, sigma = signal1.mean(), signal1.std()
x_fit = np.linspace(signal1.min(), signal1.max(), 100)
ax4.plot(x_fit, stats.norm.pdf(x_fit, mu, sigma),
color=colors['secondary'], linewidth=2,
linestyle='--', label=f'μ={mu:.2f}, σ={sigma:.2f}')
ax4.set_title('Distribution', fontsize=12, fontweight='bold')
ax4.set_xlabel('Value', fontsize=10)
ax4.set_ylabel('Density', fontsize=10)
ax4.legend(fontsize=9)
ax4.grid(True, alpha=0.3, axis='y')
# Correlation plot
ax5 = fig.add_subplot(gs[1, 2])
correlation = np.corrcoef(signal1[:500].reshape(50, 10).T)
im = ax5.imshow(correlation, cmap='RdBu_r', vmin=-1, vmax=1)
ax5.set_title('Correlation Matrix', fontsize=12, fontweight='bold')
ax5.set_xlabel('Component', fontsize=10)
ax5.set_ylabel('Component', fontsize=10)
# Add colorbar
cbar = plt.colorbar(im, ax=ax5, fraction=0.046, pad=0.04)
cbar.set_label('Correlation', fontsize=10)
# Statistical summary table
ax6 = fig.add_subplot(gs[2, :])
ax6.axis('tight')
ax6.axis('off')
# Create summary statistics
stats_data = {
'Metric': ['Mean', 'Std Dev', 'Skewness', 'Kurtosis', 'Max', 'Min'],
'Signal A': [f'{signal1.mean():.3f}', f'{signal1.std():.3f}',
f'{stats.skew(signal1):.3f}', f'{stats.kurtosis(signal1):.3f}',
f'{signal1.max():.3f}', f'{signal1.min():.3f}'],
'Signal B': [f'{signal2.mean():.3f}', f'{signal2.std():.3f}',
f'{stats.skew(signal2):.3f}', f'{stats.kurtosis(signal2):.3f}',
f'{signal2.max():.3f}', f'{signal2.min():.3f}']
}
# Create table
table_data = list(zip(stats_data['Metric'],
stats_data['Signal A'],
stats_data['Signal B']))
table = ax6.table(cellText=table_data,
colLabels=['Metric', 'Signal A', 'Signal B'],
cellLoc='center',
loc='center',
colWidths=[0.15, 0.15, 0.15])
# Style the table
table.auto_set_font_size(False)
table.set_fontsize(10)
table.scale(1, 1.5)
# Color header
for i in range(3):
table[(0, i)].set_facecolor(colors['primary'])
table[(0, i)].set_text_props(weight='bold', color='white')
# Alternate row colors
for i in range(1, len(stats_data['Metric']) + 1):
for j in range(3):
if i % 2 == 0:
table[(i, j)].set_facecolor(colors['light'])
# Add figure title and metadata
fig.suptitle('Comprehensive Signal Analysis Report',
fontsize=16, fontweight='bold', y=0.98)
# Add footer with metadata
fig.text(0.99, 0.01,
f'Generated: {pd.Timestamp.now().strftime("%Y-%m-%d %H:%M")} | ' +
f'Data points: {len(signal1)} | ' +
f'Sampling rate: {1/(time[1]-time[0]):.1f} Hz',
fontsize=8, ha='right', style='italic', color='gray')
# Add data source
fig.text(0.01, 0.01,
'Source: Experimental Dataset #2024-A1',
fontsize=8, ha='left', style='italic', color='gray')
# Add custom legend for the entire figure
legend_elements = [
mpatches.Patch(color=colors['primary'], label='Primary Signal'),
mpatches.Patch(color=colors['secondary'], label='Secondary Signal'),
mpatches.Patch(color=colors['tertiary'], alpha=0.3, label='Region of Interest'),
]
fig.legend(handles=legend_elements,
loc='upper center',
bbox_to_anchor=(0.5, 0.95),
ncol=3,
frameon=True,
fancybox=True,
shadow=True,
fontsize=9)
# Adjust layout to prevent overlap
plt.tight_layout(rect=[0, 0.02, 1, 0.93])
# Save figure
# plt.savefig('publication_figure.pdf', dpi=300, bbox_inches='tight')
# plt.savefig('publication_figure.png', dpi=300, bbox_inches='tight',
# facecolor='white', edgecolor='none')
plt.show()
Keep a learning journal — digital or physical. After this lesson, take a few minutes to write down:
✍️ This lesson's prompt: Think of a chart you found confusing or ugly recently. Which single customization — color, typography, annotation, or layout — would have made it clearer, and why does that one change matter most?
rcParams let you define an aesthetic once and reuse it everywhere for consistent, professional output.You can now take a bland default Matplotlib chart and turn it into a clear, branded, publication-ready figure — controlling color, text, annotations, and overall style with intention.
plt.style.use() and setting rcParams?plt.style.use() applies a named, prepackaged bundle of settings, while editing plt.rcParams (or using a .mplstyle file) lets you set individual defaults yourself. A style sheet is really just a saved collection of rcParams values.
Match the colormap to the data: sequential (like viridis) for ordered magnitudes, diverging (like RdBu_r) for data centered on a midpoint, and qualitative (like Set3) for unordered categories. Prefer perceptually uniform, colorblind-friendly maps such as viridis or cividis.
Define your customizations once in a .mplstyle file or an rcParams dictionary, then apply it at the top of every notebook or script (or use plt.rc_context() for temporary changes). This gives every figure the same fonts, colors, and spacing.
Now that your figures look the way you want, the next step is getting them out of the notebook — saving them at the right resolution and format for reports, papers, and the web.
.mplstyle file and load it to confirm your defaults apply automatically.Great visualizations are made, not found. Every small styling choice you master now compounds into figures that people actually stop to read — and trust.