Hot Take: There Are Only Four Data Visualization PatternsーThe Rest Are Just Fancy Pansies
visualization
ggplot
plotnine
Author
cstorm125
Published
July 10, 2025
Here me out. Data visualization is one of my biggest hobbies. In fact, back in 2019, I reproduced all 60+ plots from the Financial Times Visual Vocabulary using ggplot’s Python port plotnine. Just because I can. However, one thing I noticed since moving to Big Retail is that people really go out of their ways to prioritize substance over flair. We are famous for writing documents over slides; but I have never heard someone mentioned that the most common data visualization instrument is a table. I believe this is because if you are given time to carefully read and understand (10-30 minutes doc read vs 1 minute per slide), you can comprehend what even the most simplistic visualization like a table is trying to tell you. My premise is that if our goal is to tell a meaningful story with data, to a reasonable human being given reasonable time to consume, there are only four data visualization patterns you need: line plots, bar charts, histograms, and scatter plots.
featured_image
Let us reuse the transaction-level UCI Online Retail dataset with category column we added using LLM in the sales prediction post. Imagine you are a consultant the UK shop owner hired to have a look at their business during a time period. What are the key visualizations you need to understand the data and make critical suggestions for improvement?
TL;DR
Line plots display changes over time.
Bar charts compare numerical values among categorical variables.
Histograms show how a numerical variable is distributed; box/violin/swarm plots are basically putting histograms next to one another.
Scatter plots demonstrate relationship between two numerical variables; heatmaps do the same for two discrete variables.
Code
from plotnine import*from mizani.labels import*from mizani.formatters import*import seaborn as snsimport plotly.express as pximport matplotlib.pyplot as pltimport pandas as pdimport numpy as npfrom tqdm.auto import tqdmimport datetimefrom ucimlrepo import fetch_ucirepo def string_to_yearmon(date): date = date.split() date = date[0].split('/') + date[1].split(':') date = date[2] +'-'+ date[0].zfill(2)return datedef string_to_date(date): date = date.split() date = date[0].split('/') + date[1].split(':') date = date[2] +'-'+ date[0].zfill(2) +'-'+ date[1].zfill(2) #+ ' ' + date[3].zfill(2) + ':' + date[4].zfill(2)return datedef date_to_dow(date_string): dt_object = datetime.datetime.strptime(date_string, '%Y-%m-%d') dow_number = dt_object.strftime('%w') dow_name = dt_object.strftime('%A')returnf"{dow_number}_{dow_name}"online_retail = fetch_ucirepo(id=352) transaction_df = online_retail['data']['original']original_nb = transaction_df.shape[0]#create yearmon for train-valid splittransaction_df['yearmon'] = transaction_df.InvoiceDate.map(string_to_yearmon)transaction_df['invoice_date'] = transaction_df.InvoiceDate.map(string_to_date)transaction_df['dow'] = transaction_df.invoice_date.map(date_to_dow)#get rid of transactions without cidtransaction_df = transaction_df[~transaction_df.CustomerID.isna()].reset_index(drop=True)has_cid_nb = transaction_df.shape[0]#fill in unknown descriptionstransaction_df.Description = transaction_df.Description.fillna('UNKNOWN')#convert customer id to stringtransaction_df['CustomerID'] = transaction_df['CustomerID'].map(lambda x: str(int(x)))#simplify by filtering unit price and quantity to be non-zero (get rid of discounts, cancellations, etc)transaction_df = transaction_df[(transaction_df.UnitPrice>0)&\ (transaction_df.Quantity>0)].reset_index(drop=True)has_sales_nb = transaction_df.shape[0]#add salestransaction_df['Sales'] = transaction_df.UnitPrice * transaction_df.Quantity#clean descriptiontransaction_df['Description'] = transaction_df['Description'].map(lambda x: x.replace('.','').strip())#attach categoryproduct_description_category = pd.read_csv('../../data/sales_prediction/product_description_category.csv', sep='|')transaction_category_df = transaction_df.merge(product_description_category,left_on='Description', right_on='product_description', how='left')#fill in unknown if no categorytransaction_category_df['category'] = transaction_category_df['category'].fillna('Unknown')#focus on UK store; we have incomplete data for 2011-12 so we let's remove itdf = transaction_category_df[(transaction_category_df.Country=='United Kingdom')& (transaction_category_df.yearmon<'2011-12')]\ .drop(['Country','Description','InvoiceDate'], axis=1)\ .reset_index(drop=True)#reshuffle columnsdf = df[['invoice_date','InvoiceNo','CustomerID','StockCode','product_description','category','UnitPrice','Quantity','Sales','yearmon','dow']]df.tail(5)
invoice_date
InvoiceNo
CustomerID
StockCode
product_description
category
UnitPrice
Quantity
Sales
yearmon
dow
340668
2011-11-30
579885
15444
85034C
3 ROSE MORRIS BOXED CANDLES
Home Decor
1.25
4
5.00
2011-11
3_Wednesday
340669
2011-11-30
579885
15444
21742
LARGE ROUND WICKER PLATTER
Kitchen and Dining
5.95
2
11.90
2011-11
3_Wednesday
340670
2011-11-30
579885
15444
23084
RABBIT NIGHT LIGHT
Home Decor
2.08
6
12.48
2011-11
3_Wednesday
340671
2011-11-30
579885
15444
21257
VICTORIAN SEWING BOX MEDIUM
Stationary and Gifts
7.95
1
7.95
2011-11
3_Wednesday
340672
2011-11-30
579885
15444
21259
VICTORIAN SEWING BOX SMALL
Home Decor
5.95
1
5.95
2011-11
3_Wednesday
Before the exploration, we need to know the scope of the dataset. Since the dataset only contains one year’s worth of transaction records, we will not be able to perform any analysis regarding conversions (since we do not have data on those who visited but DID NOT make a purchase), profitability (since costs are not available), and year-on-year growth (since we only have one year of data).
Code
print(f'''Time period: {df.invoice_date.min()} to {df.invoice_date.max()}Number of unique customers: {df.CustomerID.nunique()}Number of unique products: {df.StockCode.nunique()}Number of unique categories: {df.category.nunique()}Number of unique transactions: {df.InvoiceNo.nunique()}Total Sales: {df.Sales.sum()}''')
Time period: 2010-12-01 to 2011-11-30
Number of unique customers: 3886
Number of unique products: 3636
Number of unique categories: 10
Number of unique transactions: 15940
Total Sales: 6872399.784
With that out of the way, let us get exploring.
Business at a Glance
The first thing we want to look at is how the 6.9M GBP total sales is spread out during the year. We see the typical year-end peak season in retail. Even without year-on-year data, by comparing 2010-12 and 2011-11, we can see that the current year’s peak is likely 2x the previous year’s. Business seems to be going well overall.
Looking at the same line plot by category, we can see the shop focuses on Home Decor and Kitchen and Dining. During peak season, gift-worthy categories namely Seasonal and Holiday, Personal Care and Wellness, and Toys and Games made substantial jump in terms of sales.
The seasonality of each category is even more transparent when we look at the faceted line plots. For instance, while most categories follow the peak-at-year-end pattern, we notice Outdoor and Garden had its peak during the spring months and Kitchen and Dining has stable sales throughout the year.
While we are at seasonality, let us look at average sales by day of week to see which ones are the most and least busy. If the shop owner has not told us, we now know that they close on Saturdays and usually have a slow Sunday. Perhaps we can consider promotions specific to Sundays to smoothen the sales during the week.
We now turn our attention to the product offerings. The bar charts reaffirm our focus on Home Decor and Kitchen and Dining with over half of total sales coming from those categories. We see similar tendencies for number of unique products per category.
By comparing share of sales against share of number of unique products in each category, we can identify which categories punch above their weights. The diagonal line represents a fair share; for instance, if a category has 20% of the products, it should contribute 20% of sales. Categories above the line earn more than their product count would suggest and vice versa. Now, each category has a different sales-vs-product-count dynamics, so it does that mean that we should be looking to cut all tail products in categories under the line. For example, Stationary and Gifts might have organically higher product count than Kitchen and Dining. Nonetheless, it is worth having a look when the imbalance is too extreme to streamline our inventory.
Code
agg_sales = df.groupby('category').Sales.sum().reset_index()agg_sales['sales_pct'] = agg_sales.Sales / agg_sales.Sales.sum()agg_stock = df.groupby('category').StockCode.nunique().reset_index()agg_stock['stock_pct'] = agg_stock.StockCode / agg_stock.StockCode.sum()agg = agg_sales[['category','sales_pct']].merge(agg_stock[['category','stock_pct']], on='category')g = (ggplot(agg, aes(x='stock_pct', y='sales_pct', label='category'))+ geom_point(color='darkred', size=3)+ geom_abline(intercept=0, slope=1, linetype='dashed', color='grey')+ geom_text(nudge_y=0.02, size=7)+ xlab('Share of Number of Products')+ ylab('Share of Sales')+ scale_x_continuous(labels=percent_format())+ scale_y_continuous(labels=percent_format())+ theme_538())g
Without stock purchase information, we cannot calculate inventory turnover. Nonetheless, we can still look at the cumulative sales percentage to determine the head products, about 750 items (about 20% of all unique products) that contribute to 80% of the sales. These head products are the main reason customers visit our shop; they form the basis of our product strategy. We either shift investments from tail products to these head products if their profit margins are high enough, or use them as gateway products for customers to purchase from other profitable items.
Next, we turn our attention to pricing. Histograms are a perfect tool to see how numerical values are spread out, such as that of price distribution. The median and mean unit prices are 2.08 GBP and 8.02 GBP respectively. The plot below gives us an intuition on the prices customers expect to see when visiting our shop.
Code
d = df[['StockCode','UnitPrice']].drop_duplicates()d['UnitPrice_capped'] = d['UnitPrice'].map(lambda x: x if x<10else10)g = (ggplot(d, aes(x='UnitPrice_capped'))+ geom_histogram(bins=20,fill='darkred')+ geom_vline(xintercept=d.UnitPrice.mean(),color='orange')+ geom_vline(xintercept=d.UnitPrice.median(),color='black')+ geom_text( x = d.UnitPrice.mean()+1, y =1000, label =round(d.UnitPrice.mean(),2), color='orange')+ geom_text( x = d.UnitPrice.median()+1, y =1000, label =round(d.UnitPrice.median(),2), color='black')+ xlab('Unit Price (winsorized at 10 GBP)') + ylab('Number of Unique Products') + scale_y_continuous(labels=label_comma())+ theme_538() )g
You can see 95% of our products are priced below 11 GBP. And the 0.1% with unusually high price has a high chance of being data input mistakes.
Code
d.UnitPrice.describe(percentiles=[i/10for i inrange(10)]+[.95,.999])
We can also look at price distribution by category. There are various flavors of putting histograms side-by-side namely box (showing median and interquantile ranges), violin (showing smoothed kernel density) and swarm (showing non-overlapping points) plots.
Code
d = df.groupby('StockCode')[['category','UnitPrice']].max().reset_index()d['UnitPrice_capped'] = d['UnitPrice'].map(lambda x: x if x<10else10)g = (ggplot(d, aes(x='category',y='UnitPrice_capped',fill='category'))+ geom_boxplot()+ xlab('Category') + ylab('Unit Price Distribution (winsorized at 10 GBP)') + theme_538() + theme(axis_text_x = element_blank()) )g
Next, let us look at how product prices move over time. By plotting the monthly median unit price for our top categories, we can identify when prices tend to spike or dip. For a shop owner, this is operationally useful: if you know a product category gets more expensive later in the year (supplier price hikes, seasonal demand), that is your cue to stock up earlier when it is cheap.
In this particular dataset, we only have selling prices not buying ones, so our analysis is limited. Nevertheless, we notice that median prices are fairly stable for most categories. Seasonal and Holiday shows the most volatility, with its median price actually dipping in Q4. This implies that the holiday rush brings in a flood of cheap, high-volume items (stocking fillers, party favors, wrapping supplies) that pull the median down even as total sales in the category explode. For the buyer, this means the peak season playbook for Seasonal and Holiday is prioritizing volume, not markup.
Price sensitivity is another angle worth investigating. We can approximate it by looking at how much variation there is in unit prices within each category (standard deviation / mean; coefficient of variation; higher equals more variability). Categories with a wide spread of prices likely have both budget and premium tiers, meaning we have room to play with discounts on the low end without cannibalizing the high-margin premium products.
Code
d = df[(df.category!='Unknown')&(df.category!='Others')].groupby(['category','StockCode']).UnitPrice.median().reset_index()d['UnitPrice_capped'] = d['UnitPrice'].map(lambda x: x if x<20else20)agg = d.groupby('category').agg( price_mean=('UnitPrice','mean'), price_std=('UnitPrice','std'), n_products=('StockCode','nunique')).reset_index()agg['cv'] = agg.price_std / agg.price_meanagg = agg.sort_values('cv', ascending=False).reset_index(drop=True)agg['category'] = pd.Categorical(agg['category'], categories=agg['category'], ordered=True)g = (ggplot(agg, aes(x='category', y='cv'))+ geom_col(fill='darkred')+ xlab('Category') + ylab('Coefficient of Variation (Price)')+ theme_538() + theme(axis_text_x=element_text(angle=90, hjust=1)))g
Categories with a high coefficient of variation (like Home Decor and Kitchen and Dining) have wide price ranges. These are categories where selective discounting on budget products can drive foot traffic without hurting the perceived value of premium items. On the flip side, categories with low variation (like Fashion Accessories and Personal Care and Wellness) are more price-uniform; discounting here risks training customers to wait for sales.
We can also visualize the relationship between median price level and price variability directly. Categories in the top-right (higher price, high CV) have both the (supposedly high; again impossible to know without cost data) margin headroom and customer expectation of varied pricing, making them the safest candidates for selective discounts. Those in the bottom-left (cheap, uniform pricing) are the ones to be careful with; (supposedly) thin margins and customers anchored to a single price point.
Code
g = (ggplot(agg, aes(x='price_mean', y='cv', label='category'))+ geom_point(color='darkred', size=3)+ geom_text(nudge_y=0.02, size=7)+ xlab('Mean Unit Price (GBP)')+ ylab('Coefficient of Variation (Price)')+ theme_538())g
Finally, let us look at basket size over time. Basket size (number of units per transaction) tells us about purchasing behavior. If baskets are getting larger, customers are consolidating trips; if they are shrinking, people might be making more frequent small purchases. The median basket size stays remarkably stable around the same level throughout the year (approximately 150 units per transaction since many customers are wholesalers), even during peak season when total sales explode. This tells us the peak is driven by more customers shopping rather than existing customers buying more per trip. If we want to increase average order value, we could consider bundle deals or quantity discounts, especially heading into the holidays when traffic is already high.
Understanding our customer base is arguably the most actionable part of any retail analysis. Let us start by looking at the distribution of total sales per customer. This histogram immediately reveals who our core customers are.
Code
d = df.groupby('CustomerID').Sales.sum().reset_index()d['Sales_capped'] = d['Sales'].map(lambda x: x if x<5000else5000)g = (ggplot(d, aes(x='Sales_capped'))+ geom_histogram(bins=50, fill='darkred')+ geom_vline(xintercept=d.Sales.mean(), color='orange')+ geom_vline(xintercept=d.Sales.median(), color='black')+ geom_text( x=d.Sales.mean()+500, y=200, label=f"mean: {round(d.Sales.mean(),0):.0f}", color='orange')+ geom_text( x=d.Sales.median()+500, y=250, label=f"median: {round(d.Sales.median(),0):.0f}", color='black')+ xlab('Total Sales per Customer (winsorized at 5,000 GBP)')+ ylab('Number of Customers')+ scale_y_continuous(labels=label_comma())+ theme_538())g
As is typical in retail, we have a highly skewed distribution. The median customer spends far less than the mean, meaning a small group of whales drives a disproportionate share of revenue. Let us quantify that.
Code
d = df.groupby('CustomerID').Sales.sum().reset_index()\ .sort_values('Sales', ascending=False).reset_index(drop=True)d['sales_pct'] = d.Sales / d.Sales.sum()d['cumu_sales_pct'] = d.sales_pct.cumsum()d = d.reset_index()g = (ggplot(d, aes(x='index', y='cumu_sales_pct', group=1))+ geom_line(color='darkred')+ scale_y_continuous(labels=percent_format(), breaks=[i/10for i inrange(11)])+ scale_x_continuous(labels=label_comma())+ xlab('Number of Customers (ranked by spend)')+ ylab('Cumulative Sales Percentage')+ theme_538())g
In classic Pareto fashion, about 20% of customers account for roughly 80% of sales. These top spenders are the ones we absolutely cannot afford to lose. Any loyalty program, personalized offer, or VIP treatment should start with this cohort.
For customer segmentation, Recency-Frequency-Monetary (RFM) analysis is the tried-and-true approach. We compute each metric per customer, then visualize how they relate to one another using scatter plots. The scatter plot reveals a clear pattern: high-frequency customers tend to be high-monetary, and most of them have also made a recent purchase. The light-colored dots at high frequency/monetary are concerning; these are formerly valuable customers who have not come back in a while. They are prime targets for reactivation campaigns.
Let us see which categories drive the most cross-shopping. A heatmap, the scatter plot’s cousin for discrete variables, of co-purchase rates across categories tells us which combinations appear together in the same basket more often than chance would predict. The heatmap confirms that Home Decor is the gateway category. It co-occurs with nearly every other category at high rates. This makes it a natural candidate for cross-sell recommendations. Conversely, Outdoor and Garden and Toys and Games are fairly self-contained; customers buying in those categories tend to not browse much elsewhere in the same trip.
Code
cats = df[~df.category.isin(['Unknown','Others'])].category.unique().tolist()basket_cat = df[~df.category.isin(['Unknown','Others'])].groupby('InvoiceNo').category.apply(set).reset_index()co_purchase = pd.DataFrame(0, index=cats, columns=cats)for _, row in basket_cat.iterrows():for c1 in row.category:for c2 in row.category: co_purchase.loc[c1, c2] +=1# Normalize by the diagonal (self-occurrence) to get co-purchase ratefor c in cats: co_purchase[c] = co_purchase[c] / co_purchase.loc[c, c]# Melt for plottingco_purchase_melted = co_purchase.reset_index().melt(id_vars='index')co_purchase_melted.columns = ['category_1', 'category_2', 'co_purchase_rate']# Remove self pairs for clarityco_purchase_melted = co_purchase_melted[ co_purchase_melted.category_1 != co_purchase_melted.category_2]g = (ggplot(co_purchase_melted, aes(x='category_1', y='category_2', fill='co_purchase_rate'))+ geom_tile()+ geom_text(aes(label='co_purchase_rate'), format_string='{:.0%}', size=7)+ scale_fill_gradient(low='white', high='darkred')+ xlab('') + ylab('')+ theme_538()+ theme(axis_text_x=element_text(angle=90, hjust=1), figure_size=(8, 6)))g
Another important angle is to see whether we are growing or just milking from our customer base. We can split each month’s purchasing customers into new and existing customers to see how both groups trend over time. The two lines tell a clear story: existing customers climb steadily as the base accumulates, while new customer acquisition stays relatively flat. The gap widens over time, which means growth is increasingly driven by retention rather than acquisition. This is not necessarily bad since it means we are successfully retaining customers, but if growth is a priority, we need to invest more in top-of-funnel activities.
We can dig deeper with a cohort retention heatmap. For each acquisition cohort (month of first purchase), we track what percentage of those customers are still buying in subsequent months. The diagonal is always 100% (month of acquisition). How quickly the color fades as you move right tells you how fast each cohort churns. Earlier cohorts that still show color in later months are your loyal base. If recent cohorts drop off faster than older ones, your product-market fit or onboarding experience might be degrading.
Additionally, we may want to know which categories lead to the highest repeat rates and highest customer lifetime value. These are the categories worth investing in to cultivate as sacrificing short-term profits might lead to higher long-term benefits. Personal Care and Wellness emerges as a great candidate from the bar charts below with an exceptionally high lifetime value, while repeat rates are about the same across all categories.
Finally, let us look at repurchase periods. For each customer with more than one transaction, we compute the average number of days between purchases. This tells us when to reach out with a reactivation nudge. The median repurchase period gives us our magic number. If a customer has not come back within, say, 1.5x their expected repurchase period, they are at risk of churning. That is exactly when you fire off an email, a discount code, or a “we miss you” campaign.
print(f'''--- Repurchase Period Statistics ---Mean days between purchases: {avg_repurchase.avg_days_between.mean():.0f} daysMedian days between purchases: {avg_repurchase.avg_days_between.median():.0f} daysSuggested reactivation trigger: {avg_repurchase.avg_days_between.median()*1.5:.0f} days since last purchase''')
--- Repurchase Period Statistics ---
Mean days between purchases: 78 days
Median days between purchases: 59 days
Suggested reactivation trigger: 89 days since last purchase
So What Did We Learn?
With nothing more than line plots, bar charts, histograms, and scatter plots (plus their close relatives–heatmaps and box plots), we were able to paint a fairly comprehensive picture of this retail business:
Sales trajectory growing year-on-year, with clear seasonality and a 2x peak in November. Sundays are slow; Saturdays are closed.
Category dynamicsHome Decor and Kitchen and Dining dominate; seasonal categories spike predictably at year-end while Outdoor and Garden peaks in spring.
Product strategy 20% of SKUs drive 80% of revenue. Some categories carry too many products for their revenue contribution.
Pricing most products sit below 10 GBP. Categories with high price variability are safer to discount selectively.
Customer concentration classic Pareto distribution, a small whale cohort drives most of the revenue.
Retention vs acquisition existing customers are the engine; new customer acquisition is flat. Cohort retention fades predictably.
Cross-sellingHome Decor is the gateway category; co-purchase patterns suggest clear recommendation opportunities.
Reactivation timing the median repurchase period gives us a concrete trigger for win-back campaigns.
All of this, and we still have blind spots. Without cost data, we can only make educated guesses about profitability. Without inventory and procurement records, inventory turnover and stockout rates remain unknown. Without traffic or impression data, conversion rates are invisible.
But the point of this article is not about this particular dataset. It is about the fact that we got here using the most boring visualization toolkit imaginable: no Sankey diagrams, Marimekko charts, treemaps, chord diagrams, 3D surface plots, nor animated racing bar charts; just lines, bars, histograms, and dots on a plane.
For data visualization, the most critical part has never been how beautiful it looks. It is about visualizing with intent: what question each plot answers and what action follows from that answer. Respect your audience’s intellectual capacity and assume that they have the attention span to carefully read a few tables and your Four Horsemen of Data Visualization.