Skip to content

Contour and Surface Plots

This document covers techniques for combining 2D contour plots with 3D surface visualizations to provide complementary views of the same data.

Mental Model

A surface plot shows the full 3D shape while a contour plot shows the same data as a flat map with level curves. Placing them side by side (or projecting contours onto the base of a 3D plot) gives readers two complementary viewpoints: the surface reveals peaks and valleys, while contours reveal precise level sets and gradients.

Choosing the Right Representation

All four visualizations show the same scalar field \(z = f(x, y)\) from different perspectives:

Representation Best for Perspective
contour (lines) Precision reading of exact values Top-down
contourf (filled) Pattern recognition, regional trends Top-down
Surface plot Geometric intuition (peaks, valleys, saddles) 3D
Projection (contour on 3D base) Connecting 2D level sets to 3D shape Hybrid

Use contour + contourf together for the most readable 2D map. Use surface + projected contour for the most complete 3D view.

Setup

```python import matplotlib.pyplot as plt import numpy as np from mpl_toolkits.mplot3d import Axes3D

x = np.linspace(-3, 3, 100) y = np.linspace(-3, 3, 100) X, Y = np.meshgrid(x, y) Z = np.exp(-(X2 + Y2)) ```


Side-by-Side Visualization

1. Basic Comparison

```python fig = plt.figure(figsize=(14, 5))

2D Contour

ax1 = fig.add_subplot(1, 2, 1) cf = ax1.contourf(X, Y, Z, levels=20, cmap='viridis') ax1.contour(X, Y, Z, levels=10, colors='black', linewidths=0.5) ax1.set_xlabel('x') ax1.set_ylabel('y') ax1.set_title('2D Contour Plot') ax1.set_aspect('equal') plt.colorbar(cf, ax=ax1)

3D Surface

ax2 = fig.add_subplot(1, 2, 2, projection='3d') ax2.plot_surface(X, Y, Z, cmap='viridis', linewidth=0) ax2.set_xlabel('x') ax2.set_ylabel('y') ax2.set_zlabel('z') ax2.set_title('3D Surface Plot')

plt.tight_layout() plt.show() ```

2. Multiple View Angles

```python fig = plt.figure(figsize=(15, 10))

Contour plot

ax1 = fig.add_subplot(2, 2, 1) cf = ax1.contourf(X, Y, Z, levels=15, cmap='viridis') cs = ax1.contour(X, Y, Z, levels=8, colors='white', linewidths=0.5) ax1.clabel(cs, inline=True, fontsize=8) ax1.set_title('Contour (Top View)') ax1.set_aspect('equal') plt.colorbar(cf, ax=ax1)

3D views

views = [(30, -60), (60, -60), (30, 45)] titles = ['Standard View', 'High Angle', 'Rotated View']

for idx, ((elev, azim), title) in enumerate(zip(views, titles)): ax = fig.add_subplot(2, 2, idx + 2, projection='3d') ax.plot_surface(X, Y, Z, cmap='viridis', linewidth=0) ax.view_init(elev=elev, azim=azim) ax.set_title(f'3D Surface: {title}') ax.set_xlabel('x') ax.set_ylabel('y')

plt.tight_layout() plt.show() ```


Contour Projection on 3D Plot

Project contours onto the base plane of a 3D surface plot.

1. Basic Projection

```python fig, ax = plt.subplots(figsize=(10, 8), subplot_kw={'projection': '3d'})

Surface plot

ax.plot_surface(X, Y, Z, cmap='viridis', alpha=0.8)

Contour projection on z=0 plane

ax.contour(X, Y, Z, zdir='z', offset=0, cmap='viridis', levels=10)

ax.set_xlabel('x') ax.set_ylabel('y') ax.set_zlabel('z') ax.set_title('Surface with Contour Projection') plt.show() ```

2. Filled Contour Projection

```python fig, ax = plt.subplots(figsize=(10, 8), subplot_kw={'projection': '3d'})

Surface plot

ax.plot_surface(X, Y, Z, cmap='viridis', alpha=0.7)

Filled contour projection

ax.contourf(X, Y, Z, zdir='z', offset=-0.2, cmap='viridis', levels=15, alpha=0.8)

ax.set_xlabel('x') ax.set_ylabel('y') ax.set_zlabel('z') ax.set_zlim(-0.2, 1.1) ax.set_title('Surface with Filled Contour Projection') plt.show() ```

3. Multiple Projections

```python fig, ax = plt.subplots(figsize=(12, 10), subplot_kw={'projection': '3d'})

Surface

surf = ax.plot_surface(X, Y, Z, cmap='viridis', alpha=0.6)

XY plane projection (bottom)

ax.contourf(X, Y, Z, zdir='z', offset=-0.1, cmap='viridis', levels=10, alpha=0.7)

XZ plane projection (back)

ax.contourf(X, Y, Z, zdir='y', offset=3.5, cmap='viridis', levels=10, alpha=0.5)

YZ plane projection (side)

ax.contourf(X, Y, Z, zdir='x', offset=-3.5, cmap='viridis', levels=10, alpha=0.5)

ax.set_xlabel('x') ax.set_ylabel('y') ax.set_zlabel('z') ax.set_xlim(-3.5, 3) ax.set_ylim(-3, 3.5) ax.set_zlim(-0.1, 1.1) ax.set_title('Surface with Multiple Contour Projections') plt.show() ```


Wireframe with Contours

1. Wireframe Surface

```python fig = plt.figure(figsize=(14, 5))

Filled contour

ax1 = fig.add_subplot(1, 2, 1) cf = ax1.contourf(X, Y, Z, levels=20, cmap='coolwarm') ax1.set_title('Filled Contour') ax1.set_aspect('equal') plt.colorbar(cf, ax=ax1)

Wireframe

ax2 = fig.add_subplot(1, 2, 2, projection='3d') ax2.plot_wireframe(X, Y, Z, rstride=5, cstride=5, color='blue', linewidth=0.5) ax2.set_title('Wireframe')

plt.tight_layout() plt.show() ```

2. Wireframe with Contour Projection

```python fig, ax = plt.subplots(figsize=(10, 8), subplot_kw={'projection': '3d'})

Wireframe

ax.plot_wireframe(X, Y, Z, rstride=5, cstride=5, color='navy', linewidth=0.5, alpha=0.7)

Contour projection

ax.contour(X, Y, Z, zdir='z', offset=0, cmap='Blues', levels=10)

ax.set_zlim(0, 1.1) ax.set_title('Wireframe with Contour Projection') plt.show() ```


1. Gaussian Function

```python Z_gauss = np.exp(-(X2 + Y2))

fig = plt.figure(figsize=(14, 5))

ax1 = fig.add_subplot(1, 2, 1) cf = ax1.contourf(X, Y, Z_gauss, levels=20, cmap='viridis') ax1.contour(X, Y, Z_gauss, levels=10, colors='white', linewidths=0.5) ax1.set_title('Gaussian: \(e^{-(x^2+y^2)}\)') ax1.set_aspect('equal') plt.colorbar(cf, ax=ax1)

ax2 = fig.add_subplot(1, 2, 2, projection='3d') ax2.plot_surface(X, Y, Z_gauss, cmap='viridis') ax2.set_title('Gaussian Surface')

plt.tight_layout() plt.show() ```

2. Saddle Function

```python Z_saddle = X2 - Y2

fig = plt.figure(figsize=(14, 5))

ax1 = fig.add_subplot(1, 2, 1) cf = ax1.contourf(X, Y, Z_saddle, levels=20, cmap='coolwarm') ax1.contour(X, Y, Z_saddle, levels=[0], colors='black', linewidths=2) # Zero contour ax1.set_title('Saddle: \(x^2 - y^2\)') ax1.set_aspect('equal') plt.colorbar(cf, ax=ax1)

ax2 = fig.add_subplot(1, 2, 2, projection='3d') ax2.plot_surface(X, Y, Z_saddle, cmap='coolwarm') ax2.set_title('Saddle Surface')

plt.tight_layout() plt.show() ```

3. Sinusoidal Function

```python Z_sin = np.sin(X) * np.cos(Y)

fig = plt.figure(figsize=(14, 5))

ax1 = fig.add_subplot(1, 2, 1) cf = ax1.contourf(X, Y, Z_sin, levels=20, cmap='RdBu') ax1.contour(X, Y, Z_sin, levels=[0], colors='black', linewidths=1) ax1.set_title('\(\\sin(x)\\cos(y)\)') ax1.set_aspect('equal') plt.colorbar(cf, ax=ax1)

ax2 = fig.add_subplot(1, 2, 2, projection='3d') ax2.plot_surface(X, Y, Z_sin, cmap='RdBu') ax2.set_title('Sinusoidal Surface')

plt.tight_layout() plt.show() ```

```python functions = [ (np.exp(-(X2 + Y2)), 'Gaussian', 'viridis'), (X2 - Y2, 'Saddle', 'coolwarm'), (np.sin(X) * np.cos(Y), 'sin(x)cos(y)', 'RdBu'), (np.sin(np.sqrt(X2 + Y2)), 'Ripple', 'plasma') ]

fig = plt.figure(figsize=(16, 12))

for idx, (Z_func, title, cmap) in enumerate(functions): # Contour ax1 = fig.add_subplot(4, 2, 2*idx + 1) cf = ax1.contourf(X, Y, Z_func, levels=15, cmap=cmap) ax1.contour(X, Y, Z_func, levels=8, colors='black', linewidths=0.3) ax1.set_title(f'{title} (Contour)') ax1.set_aspect('equal') plt.colorbar(cf, ax=ax1)

# Surface
ax2 = fig.add_subplot(4, 2, 2*idx + 2, projection='3d')
ax2.plot_surface(X, Y, Z_func, cmap=cmap, linewidth=0)
ax2.set_title(f'{title} (Surface)')

plt.tight_layout() plt.show() ```


Bivariate Normal Distribution

1. Standard Normal

```python from scipy import stats

x = np.linspace(-3, 3, 100) y = np.linspace(-3, 3, 100) X, Y = np.meshgrid(x, y) pos = np.dstack((X, Y))

rv = stats.multivariate_normal([0, 0], [[1, 0], [0, 1]]) Z = rv.pdf(pos)

fig = plt.figure(figsize=(14, 5))

ax1 = fig.add_subplot(1, 2, 1) cf = ax1.contourf(X, Y, Z, levels=20, cmap='Blues') ax1.contour(X, Y, Z, levels=10, colors='navy', linewidths=0.5) ax1.set_title('Standard Bivariate Normal') ax1.set_aspect('equal') plt.colorbar(cf, ax=ax1, label='Density')

ax2 = fig.add_subplot(1, 2, 2, projection='3d') ax2.plot_surface(X, Y, Z, cmap='Blues') ax2.set_title('PDF Surface') ax2.set_zlabel('Density')

plt.tight_layout() plt.show() ```

2. Correlation Comparison

```python rhos = [-0.7, 0, 0.7]

fig = plt.figure(figsize=(15, 8))

for idx, rho in enumerate(rhos): rv = stats.multivariate_normal([0, 0], [[1, rho], [rho, 1]]) Z = rv.pdf(pos)

# Contour
ax1 = fig.add_subplot(2, 3, idx + 1)
cf = ax1.contourf(X, Y, Z, levels=15, cmap='Blues')
ax1.set_title(f'ρ = {rho}')
ax1.set_aspect('equal')
plt.colorbar(cf, ax=ax1)

# Surface
ax2 = fig.add_subplot(2, 3, idx + 4, projection='3d')
ax2.plot_surface(X, Y, Z, cmap='Blues')
ax2.set_title(f'Surface (ρ = {rho})')
ax2.view_init(30, -60)

plt.tight_layout() plt.show() ```


Interactive-Style Dashboard

```python Z = np.exp(-(X2 + Y2) / 2) / (2 * np.pi)

fig = plt.figure(figsize=(16, 10))

Main 3D surface (large)

ax_3d = fig.add_subplot(2, 2, 1, projection='3d') surf = ax_3d.plot_surface(X, Y, Z, cmap='viridis', alpha=0.9) ax_3d.contourf(X, Y, Z, zdir='z', offset=0, cmap='viridis', levels=10, alpha=0.5) ax_3d.set_xlabel('x') ax_3d.set_ylabel('y') ax_3d.set_zlabel('f(x,y)') ax_3d.set_title('3D Surface with Contour Projection', fontsize=12) ax_3d.view_init(30, -60)

Top-down contour view

ax_top = fig.add_subplot(2, 2, 2) cf = ax_top.contourf(X, Y, Z, levels=20, cmap='viridis') cs = ax_top.contour(X, Y, Z, levels=10, colors='white', linewidths=0.5) ax_top.clabel(cs, inline=True, fontsize=7, fmt='%.3f') ax_top.set_xlabel('x') ax_top.set_ylabel('y') ax_top.set_title('Contour Plot (Top View)', fontsize=12) ax_top.set_aspect('equal') plt.colorbar(cf, ax=ax_top)

X cross-section

ax_xsec = fig.add_subplot(2, 2, 3) mid_idx = len(y) // 2 ax_xsec.plot(x, Z[mid_idx, :], 'b-', linewidth=2) ax_xsec.fill_between(x, Z[mid_idx, :], alpha=0.3) ax_xsec.set_xlabel('x') ax_xsec.set_ylabel('f(x, 0)') ax_xsec.set_title('Cross-Section at y = 0', fontsize=12) ax_xsec.grid(alpha=0.3)

Y cross-section

ax_ysec = fig.add_subplot(2, 2, 4) ax_ysec.plot(y, Z[:, mid_idx], 'r-', linewidth=2) ax_ysec.fill_between(y, Z[:, mid_idx], alpha=0.3, color='red') ax_ysec.set_xlabel('y') ax_ysec.set_ylabel('f(0, y)') ax_ysec.set_title('Cross-Section at x = 0', fontsize=12) ax_ysec.grid(alpha=0.3)

plt.suptitle('Standard Bivariate Normal Distribution Analysis', fontsize=14, fontweight='bold') plt.tight_layout() plt.show() ```


Publication-Quality Figure

```python from scipy import stats

Setup

x = np.linspace(-3, 3, 150) y = np.linspace(-3, 3, 150) X, Y = np.meshgrid(x, y) pos = np.dstack((X, Y))

rho = 0.6 rv = stats.multivariate_normal([0, 0], [[1, rho], [rho, 1]]) Z = rv.pdf(pos)

fig = plt.figure(figsize=(14, 5))

Contour plot

ax1 = fig.add_subplot(1, 2, 1) cf = ax1.contourf(X, Y, Z, levels=15, cmap='Blues') cs = ax1.contour(X, Y, Z, levels=8, colors='navy', linewidths=0.6, alpha=0.8) ax1.clabel(cs, inline=True, fontsize=8, fmt='%.3f') ax1.set_xlabel('\(X\)', fontsize=12) ax1.set_ylabel('\(Y\)', fontsize=12) ax1.set_title('Contour Plot', fontsize=13) ax1.set_aspect('equal') ax1.tick_params(labelsize=10) cbar1 = plt.colorbar(cf, ax=ax1) cbar1.set_label('Probability Density', fontsize=11)

Surface plot

ax2 = fig.add_subplot(1, 2, 2, projection='3d') surf = ax2.plot_surface(X, Y, Z, cmap='Blues', linewidth=0, antialiased=True) ax2.contourf(X, Y, Z, zdir='z', offset=0, cmap='Blues', levels=10, alpha=0.5) ax2.set_xlabel('\(X\)', fontsize=11, labelpad=10) ax2.set_ylabel('\(Y\)', fontsize=11, labelpad=10) ax2.set_zlabel('Density', fontsize=11, labelpad=10) ax2.set_title('Surface Plot', fontsize=13) ax2.view_init(25, -50) ax2.tick_params(labelsize=9)

White panes

ax2.w_xaxis.set_pane_color((0.98, 0.98, 0.98, 1.0)) ax2.w_yaxis.set_pane_color((0.98, 0.98, 0.98, 1.0)) ax2.w_zaxis.set_pane_color((0.98, 0.98, 0.98, 1.0))

plt.suptitle(f'Bivariate Normal Distribution (\(\\rho = {rho}\))', fontsize=14, fontweight='bold', y=1.02) plt.tight_layout() plt.show() ```


Summary

Visualization Best For
Contour only 2D view, precise level reading
Surface only Overall shape understanding
Contour + Surface side-by-side Comprehensive analysis
Surface + Contour projection 3D context with 2D precision
Wireframe + Contour Structure visualization
Dashboard (multiple views) Complete exploration

The Unifying Idea: Scalar Field Visualization

All contour and surface plots visualize the same mathematical object: a scalar field \(z = f(x, y)\).

Each method shows it differently:

Method What it reveals Best for
contour Level sets (where values are equal) Exact values, gradients
contourf Regions (how values group) Patterns, classification
plot_surface Geometry (how values rise and fall) Shape intuition, peaks/valleys
Projection How 2D relates to 3D Connecting perspectives

Choosing the plot means choosing how to see the function. A contour plot is a projection onto the (x, y) plane; a surface plot is an embedding in 3D. They are different views of the same object --- like looking at a mountain from above (contour map) vs from the side (landscape).


Exercises

Exercise 1. Create a side-by-side figure (1x2) showing a filled contour plot on the left and a 3D surface plot on the right for the function \(z = \cos(\sqrt{x^2 + y^2})\) over \([-5, 5] \times [-5, 5]\). Use the same colormap for both.

Solution to Exercise 1
import matplotlib.pyplot as plt
import numpy as np

x = np.linspace(-5, 5, 200)
y = np.linspace(-5, 5, 200)
X, Y = np.meshgrid(x, y)
Z = np.cos(np.sqrt(X**2 + Y**2))

fig = plt.figure(figsize=(14, 5))

ax1 = fig.add_subplot(1, 2, 1)
cf = ax1.contourf(X, Y, Z, levels=20, cmap='viridis')
plt.colorbar(cf, ax=ax1)
ax1.set_title('Filled Contour')
ax1.set_aspect('equal')

ax2 = fig.add_subplot(1, 2, 2, projection='3d')
ax2.plot_surface(X, Y, Z, cmap='viridis', alpha=0.9)
ax2.set_title('3D Surface')

plt.tight_layout()
plt.show()

Exercise 2. Create a 3D surface plot of \(z = \sin(x) \cdot \cos(y)\) and project contour lines onto the z-axis floor of the 3D axes using ax.contour with the zdir='z' and offset parameters. Set the offset to the minimum z value.

Solution to Exercise 2
import matplotlib.pyplot as plt
import numpy as np

x = np.linspace(-np.pi, np.pi, 100)
y = np.linspace(-np.pi, np.pi, 100)
X, Y = np.meshgrid(x, y)
Z = np.sin(X) * np.cos(Y)

fig = plt.figure(figsize=(10, 7))
ax = fig.add_subplot(111, projection='3d')

ax.plot_surface(X, Y, Z, cmap='coolwarm', alpha=0.8)
ax.contour(X, Y, Z, zdir='z', offset=Z.min(), cmap='coolwarm', levels=15)

ax.set_zlim(Z.min() - 0.3, Z.max())
ax.set_xlabel('X')
ax.set_ylabel('Y')
ax.set_zlabel('Z')
ax.set_title('Surface with Projected Contours')
plt.show()

Exercise 3. Build a three-panel figure: top-left shows contour (line), top-right shows contourf (filled), and bottom (spanning full width) shows the 3D plot_surface. Use the function \(z = x^2 - y^2\) over \([-2, 2] \times [-2, 2]\). Use gridspec for the layout.

Solution to Exercise 3
import matplotlib.pyplot as plt
import matplotlib.gridspec as gridspec
import numpy as np

x = np.linspace(-2, 2, 100)
y = np.linspace(-2, 2, 100)
X, Y = np.meshgrid(x, y)
Z = X**2 - Y**2

fig = plt.figure(figsize=(12, 10))
gs = gridspec.GridSpec(2, 2, height_ratios=[1, 1.2])

ax1 = fig.add_subplot(gs[0, 0])
cs = ax1.contour(X, Y, Z, levels=15, cmap='RdBu')
ax1.clabel(cs, inline=True, fontsize=8)
ax1.set_title('Contour Lines')
ax1.set_aspect('equal')

ax2 = fig.add_subplot(gs[0, 1])
cf = ax2.contourf(X, Y, Z, levels=15, cmap='RdBu')
plt.colorbar(cf, ax=ax2)
ax2.set_title('Filled Contour')
ax2.set_aspect('equal')

ax3 = fig.add_subplot(gs[1, :], projection='3d')
ax3.plot_surface(X, Y, Z, cmap='RdBu', alpha=0.9)
ax3.set_title('3D Surface')

plt.tight_layout()
plt.show()