My cheatsheet for plots

13 min read Original article ↗

What makes a good plot a good plot?

What is the secret sauce? Here are some things I wish I had known back during my bachelor’s degree, which I first learned from my supervisor while working on my thesis. Many others I realized later, working at Home Credit International and later as a consultant at Nova TV.

I remember one situation clearly. After three months of work, we were asked to present the performance of the first model predicting future viewership in linear TV. I prepared line plots showing the historical ground truth and the model predictions, including error bars. We also had calibration plots. The expectations were much higher than the model actual performance, and the people in the room struggled to understand the situation from the graphs we presented.

I learned a hard lesson that day. A graph is not only about correctness. It is also about telling a story. I put too much into a small number of figures. What should have been a sequence of simple, clear visuals was compressed into five dense plots. It took a while to explain them.

Your audience

I think we can separate plots and charts into three categories:

  1. For your own use: Plots that you need to create/generate in order to get some insight into the given topic. Those should be the fastest to create, and it is more desirable to make them faster rather than aesthetically pleasing. Before ChatGPT was made publicly available in 2022 I used to have gists or kaggle notebooks with plots I prepared in past. I would copy them and then quickly tweak. After ChatGPT, it is the fastest way to get something quick from ChatGPT as a starting point. From 2022 I think the GPT-4o is able to usually get it right on the first try.

  2. For horizontal sharing: Those are the plots you will create in order to share with your peers and maybe your direct manager. They can also be archived, and even if you don’t think they can be archived, oh they absolutely can be archived without your consent. If you plan to share plots with your peers in the company, you should treat that as having only one try and after that, it will not be edited again. What does it mean for you? You should think about the message you want to present. The graph should be clean, easy to understand, self-contained and not ugly.

  3. For internal presentation or public sharing: Those are rarer and will get a broader audience. Imagine you just finished the monthly report on the predictive power of the company’s models. The result is different than usual (could be really good or really bad). You want to share this information with managers or in internal channels. What should you do? In those types of graphs, it is the highest priority that they are not overwhelmingly dense/complex. They have a clear message, they have sufficient contrast and are still rigorous. You should pay the most attention to those graphs and spend more energy so that there are no mistakes or flaws. Imagine that there will be hundreds of eyes looking at that graph, for sure, many people will notice the flaws. If the graph is too complex, you risk losing people’s attention while looking at it, and your message will not be understood.

”I need the report asap”

If you worked as Data Analyst or Data Scientist I am sure you were in a situation where you felt considerable pressure to finish the report. Most likely because it could be impactful, and in higher circles, there was high interest to see the results as soon as possible to decide the next step. If you sacrifice the quality of the plot or the clarity of the message, you have just decided to change the plot type from the third (or the second) type to the first one. Here is a simple test for your graph:

  1. Is the graph in a state where I am not sure if the data that I am presenting is correct? [Yes/No]
  2. If you were asked to explain the information that you are presenting to a teenager from a high school, would it help you to achieve that? [Yes/No]
  3. Would you be proud to show this graph to someone whose opinion matters to you? [Yes/No]

If you didn’t answer three times Yes, the graph is probably not ready to be shared internally or publicly. If you answered No to 2. or 3. and you got two Yes in total, you might consider sharing horizontally. Of course, there are exceptions to all rules, sometimes it is really urgent.

My cheatsheet

Core Principles

  • Most importantly, the plot is a message. The plot should have one key information that you want to focus on. Pick the type of graph based on the information you want to share and how you want to share it.
Click to see code example

months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun']

revenue = [100, 120, 110, 140, 160, 150]

costs = [80, 85, 90, 95, 100, 105]

profit = [r-c for r,c in zip(revenue, costs)]

employees = [50, 52, 48, 55, 58, 60]

fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 4))

ax1_twin = ax1.twinx()

ax1.bar(months, revenue, alpha=0.7, label='Revenue', color='green')

ax1.bar(months, costs, alpha=0.7, label='Costs', color='red')

ax1.plot(months, profit, 'o-', color='blue', linewidth=2, label='Profit')

ax1_twin.plot(months, employees, 's-', color='purple', linewidth=2, label='Employees')

ax1.set_title('BAD: Too many messages\nRevenue, costs, profit, AND employees')

ax1.legend(loc='upper left')

ax1_twin.legend(loc='upper right')

ax1.set_ylabel('Money ($k)')

ax1_twin.set_ylabel('Employees (#)')

ax2.bar(months, profit, color='#2E86AB')

ax2.set_title('GOOD: Single message\n"Profit increased 150% over 6 months"')

ax2.set_ylabel('Profit ($k)')

ax2.set_ylim(0, max(profit) * 1.2)

for i, val in enumerate(profit):

ax2.text(i, val + 1, f'${val}k', ha='center', va='bottom')

plt.tight_layout()

plt.show()

  • Remember, the y-axis is for the dependent variable, which is something you measure, and the x-axis is for the independent variable. Of course, it can be swapped, but this is the custom.
Click to see code example

temperature = np.array([20, 30, 40, 50, 60, 70, 80])

reaction_rate = np.array([2.1, 3.5, 5.8, 9.2, 14.1, 20.5, 28.3])

fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 4))

ax1.plot(reaction_rate, temperature, 'o-', linewidth=2, markersize=8, color='#ff7f0e')

ax1.set_title('BAD: Axes swapped\nImplies rate controls temperature')

ax1.set_xlabel('Reaction Rate (mol/s)')

ax1.set_ylabel('Temperature (°C)')

ax1.grid(True, alpha=0.3)

ax2.plot(temperature, reaction_rate, 'o-', linewidth=2, markersize=8, color='#2E86AB')

ax2.set_title('GOOD: Proper axis assignment\nTemperature controls reaction rate')

ax2.set_xlabel('Temperature (°C) - Independent')

ax2.set_ylabel('Reaction Rate (mol/s) - Dependent')

ax2.grid(True, alpha=0.3)

plt.tight_layout()

plt.show()

Tools & Libraries

  • Use matplotlib with python or mathematica. There is also R and Julia’s Plots. I know that matplotlib can be really painful, and the API is far from optimal. It can have hidden states, and it is in a much better state than in 2015, but still, you can find many bugs in it after 10 years. Matplotlib is the best library for customisation of plots. If you need something even better, you can still make the plot yourself using vector graphics.

Plot Types & When to Use Them

  • Scatterplots are chaotic and can be evil/deceptive. They are dense and hard to understand in general. They are easiest to make. Use them only when you think dumping all the datapoints makes sense.
Click to see code example

np.random.seed(42)

x_all = np.random.normal(0, 1, 2000)

y_all = 2*x_all + np.random.normal(0, 1, 2000)

fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 4))

ax1.scatter(x_all, y_all, alpha=0.6, s=10)

ax1.set_title('BAD: 2000 points\nOvercrowded, hard to see patterns')

ax1.set_xlabel('X values')

ax1.set_ylabel('Y values')

# Bin the data and calculate means and standard deviations

x_bins = np.linspace(x_all.min(), x_all.max(), 15)

x_bin_centers = (x_bins[:-1] + x_bins[1:]) / 2

y_means = []

y_stds = []

for i in range(len(x_bins)-1):

mask = (x_all >= x_bins[i]) & (x_all < x_bins[i+1])

if np.sum(mask) > 0:

y_means.append(np.mean(y_all[mask]))

y_stds.append(np.std(y_all[mask]))

else:

y_means.append(np.nan)

y_stds.append(np.nan)

valid_mask = ~np.isnan(y_means)

x_clean = x_bin_centers[valid_mask]

y_means_clean = np.array(y_means)[valid_mask]

y_stds_clean = np.array(y_stds)[valid_mask]

ax2.errorbar(

x_clean, y_means_clean, yerr=y_stds_clean,

fmt='o', capsize=5, capthick=2, markersize=6,

color='#2E86AB', ecolor='#2E86AB', alpha=0.8

)

ax2.set_title('GOOD: Binned data with error bars\nClear trend and uncertainty visible')

ax2.set_xlabel('X values')

ax2.set_ylabel('Y values')

plt.tight_layout()

plt.show()

  • In scatterplots, it doesn’t make sense to put there 1000+ points even with a small alpha. Use contour plots, they require much more effort, but they are worth it.
Click to see code example

n_points = 1000

x1 = np.random.normal(-0.6, 0.4, n_points)

y1 = 2*x1 + np.random.normal(0, 0.5, n_points)

x2 = np.random.normal(0.6, 0.4, n_points)

y2 = 2*x2 + np.random.normal(0, 0.5, n_points)

x = np.concatenate([x1, x2])

y = np.concatenate([y1, y2])

fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 4))

ax1.scatter(x, y, alpha=0.8, s=20)

ax1.set_title('BAD: Dense scatterplot\n2000+ points, hard to see patterns')

ax1.set_xlabel('X values')

ax1.set_ylabel('Y values')

hist, xedges, yedges = np.histogram2d(x, y, bins=20)

X, Y = np.meshgrid(xedges[:-1], yedges[:-1])

contour = ax2.contourf(X, Y, hist.T, levels=8, cmap='Blues')

ax2.set_title('GOOD: Contour plot\nShows density patterns clearly')

ax2.set_xlabel('X values')

ax2.set_ylabel('Y values')

plt.colorbar(contour, ax=ax2, label='Density')

plt.tight_layout()

plt.show()

  • Pie plots are good for relative comparisons only, they don’t require much attention, so you can use them as mental “breaks” in presentations or reports. They also have relatively low information value.
  • Histograms are important and powerful, but rarely good for a broader audience.
  • Box plots are excellent. I used to hate them, but the older I am, the more I like them (which maybe tells you something about the character of the plot). They are, however, better for a knowledgeable audience.
  • Heat maps are often my go-to when I need to visualise 3D data in 2D. They can be more difficult to prepare in a way that they are not completely hideous.

Visual Design & Aesthetics

  • Contrast vs. aesthetics. You can’t have a very contrastive plot with nice aesthetics. You can find balance by using dashed lines, dashed bars, but ultimately you have to pick one, or reduce the need for the number of colours.
Click to see code example

dates = pd.date_range('2023-01-01', periods=100)

series1 = np.cumsum(np.random.randn(100)) + 100

series2 = np.cumsum(np.random.randn(100)) + 95

series3 = np.cumsum(np.random.randn(100)) + 105

fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 4))

ax1.plot(dates, series1, color='#FFB3BA', linewidth=1, label='Series 1')

ax1.plot(dates, series2, color='#FFDFBA', linewidth=1, label='Series 2')

ax1.plot(dates, series3, color='#FFFFBA', linewidth=1, label='Series 3')

ax1.set_title('BAD: Low contrast\nHard to distinguish lines')

ax1.legend()

ax1.tick_params(axis='x', rotation=45)

ax2.plot(dates, series1, color='#1f77b4', linewidth=2, label='Series 1')

ax2.plot(dates, series2, color='#ff7f0e', linewidth=2, linestyle='--', label='Series 2')

ax2.plot(dates, series3, color='#2ca02c', linewidth=2, linestyle=':', label='Series 3')

ax2.set_title('GOOD: High contrast\nClear visual separation')

ax2.legend()

ax2.tick_params(axis='x', rotation=45)

plt.tight_layout()

plt.show()

  • Reduce the number of colours and reduce the number of lines. If you can’t do that, then split it into multiple plots.
Click to see code example

categories = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H']

values1 = [23, 45, 56, 78, 32, 24, 56, 78]

values2 = [34, 25, 67, 89, 43, 12, 45, 67]

fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 4))

colors_bad = plt.cm.Set3(np.linspace(0, 1, 8))

ax1.bar(categories, values1, color=colors_bad, alpha=0.8)

ax1.bar(categories, values2, color=colors_bad, alpha=0.5, bottom=values1)

ax1.set_title('BAD: Too many colors\nConfusing and overwhelming')

ax1.set_ylabel('Values')

ax2.bar(categories, values1, color='#2E86AB', alpha=0.8, label='Series 1')

ax2.bar(categories, values2, color='#A23B72', alpha=0.8, bottom=values1, label='Series 2')

ax2.set_title('GOOD: Limited colors\nClear distinction, easy to read')

ax2.set_ylabel('Values')

ax2.legend()

plt.tight_layout()

plt.show()

  • It is possible to change marker of the points even when you work with line plots, and not only scatter plots. You should absolutely utilise this feature when the number of lines is not large. Sometimes you can use this to show outliers in your data or a different trend.
  • Try to minimise the amount of text in the core part. It will only confuse people. They have limited attention unless they have to understand your graph.
Click to see code example

categories = ['Product A', 'Product B', 'Product C', 'Product D']

values = [65, 48, 73, 82]

fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 4))

bars1 = ax1.bar(categories, values, color=['red', 'blue', 'green', 'orange'])

ax1.set_title('BAD: Sales Performance Report\nQ4 2023 Results with Detailed Analysis\nShowing Revenue by Product Category')

ax1.set_xlabel('Product Categories (Measured in Units)')

ax1.set_ylabel('Revenue Values (Thousands of USD)')

for i, (cat, val) in enumerate(zip(categories, values)):

ax1.text(i, val + 2, f'{cat}:\n${val}k\n(+{np.random.randint(5,15)}% vs Q3)',

ha='center', fontsize=8)

ax1.set_ylim(0, 100)

bars2 = ax2.bar(categories, values, color='#2E86AB')

ax2.set_title('GOOD: Q4 2023 Revenue')

ax2.set_xlabel('Products')

ax2.set_ylabel('Revenue ($k)')

for i, val in enumerate(values):

ax2.text(i, val + 1, f'${val}k', ha='center', fontweight='bold')

ax2.set_ylim(0, 100)

plt.tight_layout()

plt.show()

  • Using grids is okay and often very valuable. Why should people struggle if you can help them with a grid?
Click to see code example

x = np.linspace(0, 10, 50)

y = np.sin(x) * np.exp(-x/10)

fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 4))

ax1.plot(x, y, linewidth=2, color='#1f77b4')

ax1.set_title('BAD: No grid\nHard to estimate values')

ax1.set_xlabel('X')

ax1.set_ylabel('Y')

ax2.plot(x, y, linewidth=2, color='#1f77b4')

ax2.grid(True, alpha=0.3, linestyle='-', linewidth=0.5)

ax2.set_title('GOOD: Subtle grid\nEasy to estimate values')

ax2.set_xlabel('X')

ax2.set_ylabel('Y')

plt.tight_layout()

plt.show()

Advanced Techniques & Layout

  • You can put two plots into one plot with a double y axis, use tqinx. It can be really handy.
  • When you generate multiple plots that will be compared, you should scale the y-axis to the same maximum and minimum value.
Click to see code example

data1 = [20, 25, 30, 28, 35]

data2 = [180, 185, 190, 188, 195]

labels = ['Jan', 'Feb', 'Mar', 'Apr', 'May']

fig, axes = plt.subplots(2, 2, figsize=(12, 8))

axes[0,0].bar(labels, data1, color='lightblue')

axes[0,0].set_title('BAD: Dataset 1')

axes[0,0].set_ylabel('Values')

axes[0,1].bar(labels, data2, color='lightcoral')

axes[0,1].set_title('BAD: Dataset 2')

axes[0,1].set_ylabel('Values')

max_val = max(max(data1), max(data2))

min_val = min(min(data1), min(data2))

axes[1,0].bar(labels, data1, color='lightblue')

axes[1,0].set_title('GOOD: Dataset 1')

axes[1,0].set_ylabel('Values')

axes[1,0].set_ylim(0, max_val + 10)

axes[1,1].bar(labels, data2, color='lightcoral')

axes[1,1].set_title('GOOD: Dataset 2')

axes[1,1].set_ylabel('Values')

axes[1,1].set_ylim(0, max_val + 10)

fig.suptitle('Comparing Multiple Plots: Scale Consistency Matters', fontsize=14)

plt.tight_layout()

plt.show()

  • When the distribution follows a power distribution, use log-scaled axes.
Click to see code example

x = np.logspace(0, 3, 50)

y = 1000 / (x**1.5) + np.random.exponential(10, 50)

fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 4))

ax1.plot(x, y, 'o-', markersize=4)

ax1.set_title('BAD: Linear scale\nPower law pattern unclear')

ax1.set_xlabel('X')

ax1.set_ylabel('Y')

ax1.grid(True, alpha=0.3)

ax2.loglog(x, y, 'o-', markersize=4)

ax2.set_title('GOOD: Log-log scale\nPower law clearly visible')

ax2.set_xlabel('X (log scale)')

ax2.set_ylabel('Y (log scale)')

ax2.grid(True, alpha=0.3)

plt.tight_layout()

plt.show()

  • If you could simply transform the data into (x, y) pairs where the “good” result is a simple straight line and “bad” results are deviations from the straight line, you should absolutely go for that. People will understand instantly that something is not quite right.
Click to see code example

np.random.seed(42)

x = np.linspace(5, 20, 100)

# Base quadratic relationship: y = 0.5 * x^2

y_expected = 0.5 * x**2

# Add a subtle gaussian bump anomaly around x=12.5

anomaly = 25 * np.exp(-((x - 12.5)**2) / 0.5)

y = y_expected + anomaly + np.random.normal(0, 7, len(x))

fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 4))

ax1.plot(x, y, 'o', markersize=4, alpha=0.6, color='#ff7f0e', label='Observed')

ax1.plot(x, y_expected, '-', linewidth=2, color='gray', alpha=0.5, label='Expected')

ax1.set_title('BAD: Raw data\nAnomaly around x=5 barely visible')

ax1.set_xlabel('X')

ax1.set_ylabel('Y')

ax1.legend()

ax1.grid(True, alpha=0.3)

y_transformed = np.sqrt(2 * np.abs(y))

expected_line = x

ax2.plot(x, y_transformed, 'o', markersize=4, alpha=0.6, color='#2E86AB', label='Transformed data')

ax2.plot(x, expected_line, '-', linewidth=2, color='gray', alpha=0.5, label='Expected (y=x)')

ax2.set_title('GOOD: Transformed data (√(2y) vs x)\nAnomaly clearly visible as deviation from line')

ax2.set_xlabel('X')

ax2.set_ylabel('√(2Y)')

ax2.legend()

ax2.grid(True, alpha=0.3)

plt.tight_layout()

plt.show()

  • Putting values above bars in bar plots is not prohibited, and I encourage you to do so. When you have multiple bars, perhaps you should select only a few bars for which you want to mention the exact value.
Click to see code example

categories = ['Q1', 'Q2', 'Q3', 'Q4']

values = [145, 132, 178, 195]

fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 4))

ax1.bar(categories, values, color='skyblue')

ax1.set_title('BAD: No values shown\nHard to read exact numbers')

ax1.set_ylabel('Sales (units)')

ax1.set_ylim(0, 220)

bars = ax2.bar(categories, values, color='#2E86AB')

ax2.set_title('GOOD: Values on bars\nExact numbers easily readable')

ax2.set_ylabel('Sales (units)')

ax2.set_ylim(0, 220)

for bar, val in zip(bars, values):

ax2.text(bar.get_x() + bar.get_width()/2, bar.get_height() + 5,

str(val), ha='center', va='bottom', fontweight='bold')

plt.tight_layout()

plt.show()

What NOT to Do

  • Never go 3D. I repeat, never go 3D. Or go 3D if you absolutely know what you are doing or it is only for your own use. Once people can not interact with the 3D it becomes just messy 2D.
Click to see code example

from mpl_toolkits.mplot3d import Axes3D

x = np.random.randn(50)

y = np.random.randn(50)

z = x + y + np.random.randn(50) * 0.5

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

ax1 = fig.add_subplot(121, projection='3d')

ax1.scatter(x, y, z, c=z, cmap='viridis')

ax1.set_title('BAD: 3D scatter\nHard to see exact values')

ax1.set_xlabel('X')

ax1.set_ylabel('Y')

ax1.set_zlabel('Z')

ax2 = fig.add_subplot(122)

scatter = ax2.scatter(x, y, c=z, cmap='viridis', s=50)

ax2.set_title('GOOD: 2D with color coding\nClear relationships visible')

ax2.set_xlabel('X')

ax2.set_ylabel('Y')

plt.colorbar(scatter, ax=ax2, label='Z values')

plt.tight_layout()

plt.show()

  • X-axis should have a simple rule that follows, it is unacceptable that you will manually remove the values from xticks to prevent gaps. If you are going to have gaps in the plot, so be it.
  • You should absolutely never put a plot inside a plot. Do it only when you need to save space at all costs.

What makes The Economist plots juicy?

When I was in high school, my father used to bring home The Economist publications so that I could practice and learn English. I love their plots. You should not blindly make every plot look like the economist plot, but you can take heavy inspiration from them. I highly recommend reading the following: