The Correct Way to Average the Globe | by Luke Gloege, Ph.D. | Towards Data Science

The average. It’s a simple enough calculation: add up all your data points and divide by the total number points. Does this simple equation work if you are, for instance, calculating the global average temperature? Not quite. This post outlines how to calculate the global average of a gridded dataset with dimensions: time, latitude, and longitude. In other words, calculating the mean across latitude and longitude at each time step. I first explain why it is not appropriate to calculate the spatial mean of a variable using the classic formula, I then show why a weighted-mean is appropriate, and finally give an example using a publicly available dataset.
The data I will use is a 1o x 1o gridded dataset of land and ocean temperature anomalies from Berkley Earth [1]. The following python code will download the data and read it into memory.
import xarray as xrimport pooch# save data to temporary locationflname = pooch.retrieve(‘http://berkeleyearth.lbl.gov/auto/Global/Gridded/Land_and_Ocean_LatLong1.nc’, None)# read data into memoryds = xr.open_dataset(flname)
The file’s meta-data can be viewed withds.info(). From the meta-data, we see the surface temperature anomaly variable, temperature, which is the difference between the surface air temperature and climatology. In this case the climatology is the average over January 1951 and December 1980.
The equation below is what probably comes to mind when we think of the mean of a dataset, X. This is the traditional mean. In the equation, i represents each latitude, longitude coordinate pair and N is the total number pairs.
I also call this the “flat Earth mean,” it assumes no curvature to the planet and data at each latitude and longitude location is treated equally. Why is that important? Because a 1o x 1o grid box occupies less area as it moves from the tropics to the pole. What this means is data near the equator has more influence on the mean than the arctic because it occupies more area. This suggests we should weight each data point by the area it occupies.
I also call this the “flat Earth mean,” it assumes no curvature to the planet and data at each latitude and longitude location is treated equally.
In xarray notation, this traditional or “flat Earth” mean can be calculated like this: traditional_mean = ds['temperature'].mean(['latitude','longitude'])
This is the general equation for a weighted mean:
This is similar to the traditional mean, except each data point has a weight, wi, and the denominator is the sum of all the weights. The traditional mean is just a special case of the weighted mean where each weight is equal to 1.
The traditional mean is just a special case of the weighted mean where each weight is equal to 1.
In Earth science, the preferred way is to weight the data by area, where you weight each data point by the area of each grid cell. Before we calculate the area-weighted mean, we first need to know the area of each grid cell. If the grid cell area is not provided the code below will facilitate calculating an area grid.
The shape of the Earth is an oblate spheroid, which is like “squashed” sphere. The code below calculates the grid cell area for Earth, with the radius as a function of latitude from World Geodetic System 1984. A derivation of the equation is here.
Now that we know what a weighted mean is and how to calculate an area map, let’s put all of this information together and calculate the global average mean temperature anomaly using the code below.
# area dataArrayda_area = area_grid(ds['latitude'], ds['longitude'])# total areatotal_area = da_area.sum(['latitude','longitude'])# temperature weighted by grid-cell areatemp_weighted = (ds['temperature']*da_area) / total_area# area-weighted mean temperaturetemp_weighted_mean = temp_weighted.sum(['latitude','longitude'])
Below is a plot of the global temperature anomaly, with the monthly values shown in gray and 5-year smoothed shown using the standard mean and area-weighted mean shown in red and black, respectively. The difference is slight, but the area-weighted mean shows a slightly subdued anomaly in the last 20 years. The reason is because the arctic is warming faster than lower latitudes, especially in the most recent period. The standard mean amplifies that warming, even though it accounts for a small area of the globe. The weighted-mean dampens the influence of arctic warming, providing a more accurate calculation of the average global temperature anomaly.
Weighting your data when calculating geospatial means is not just important, it is required in or order to honestly display the average and not overstate a conclusion.
Here are a few things to be mindful of when calculating global averages:
Be mindful of the total area. If you are calculating global average temperature just on land, then you need to mask out the ocean in your area dataset and normalize the weights by the total land area.If the dataset is spatially incomplete, sophisticated methods [2] are more appropriate.
Be mindful of the total area. If you are calculating global average temperature just on land, then you need to mask out the ocean in your area dataset and normalize the weights by the total land area.
If the dataset is spatially incomplete, sophisticated methods [2] are more appropriate.
In summary, area-weighting is the correct and best way to calculate global averages if you have a spatially complete gridded dataset. However, advanced techniques are required if the gridded data contains gaps. Finally, an alternative approach is to cosine weight your data, i.e. weighting your data by the cosine of latitude. The concept is the same, instead of using grid cell area, you would use the cos('latitude') as your weight matrix.
Hopefully this post provides some helpful guidance when calculating global averages.
import xarray as xr
import pooch
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.ticker import AutoMinorLocator

# save data to temporary location
flname = pooch.retrieve('http://berkeleyearth.lbl.gov/auto/Global/Gridded/Land_and_Ocean_LatLong1.nc', None)
print(flname)

# read data into memory
ds = xr.open_dataset(flname)

/home/gloege/.cache/pooch/5d70b847d9bc470e9cf3c7296348a0bc-Land_and_Ocean_LatLong1.nc

ds.info()

xarray.Dataset {
dimensions:
	latitude = 180 ;
	longitude = 360 ;
	month_number = 12 ;
	time = 2053 ;

variables:
	float32 longitude(longitude) ;
		longitude:units = degrees_east ;
		longitude:standard_name = longitude ;
		longitude:long_name = Longitude ;
	float32 latitude(latitude) ;
		latitude:units = degrees_north ;
		latitude:standard_name = latitude ;
		latitude:long_name = Latitude ;
	float64 time(time) ;
		time:units = year A.D. ;
		time:standard_name = time ;
		time:long_name = Time ;
	float64 land_mask(latitude, longitude) ;
		land_mask:units = none ;
		land_mask:standard_name = land_mask ;
		land_mask:long_name = Land Mask ;
		land_mask:valid_min = 0.0 ;
		land_mask:valid_max = 1.0 ;
	float32 temperature(time, latitude, longitude) ;
		temperature:units = degree C ;
		temperature:standard_name = surface_temperature_anomaly ;
		temperature:long_name = Air Surface Temperature Anomaly ;
		temperature:valid_min = -20.140585291401653 ;
		temperature:valid_max = 25.472171897140964 ;
	float32 climatology(month_number, latitude, longitude) ;
		climatology:units = degree C ;
		climatology:standard_name = surface_temperature_climatology ;
		climatology:long_name = Air Surface Temperature Climatology (Jan 1951 - Dec 1980) ;
		climatology:valid_min = -69.33474092282161 ;
		climatology:valid_max = 38.20322974161036 ;

// global attributes:
	:Conventions = Berkeley Earth Internal Convention (based on CF-1.5) ;
	:title = Native Format Berkeley Earth Surface Temperature Anomaly Field ;
	:history = 26-Feb-2021 07:43:08 ;
	:institution = Berkeley Earth Surface Temperature Project ;
	:land_source_history = 09-Feb-2021 03:09:39 ;
	:ocean_source_history = 26-Feb-2021 03:36:37 ;
	:comment = This file contains Berkeley Earth surface temperature anomaly field in our native equal-area format. ;
}
dates = pd.date_range(start=f'1850-01T00:00:00.000000000', 
                      end=f'2021-01T00:00:00.000000000',freq='MS')+ np.timedelta64(14, 'D')
ds['time'] = dates

def earth_radius(lat):
    '''
    calculate radius of Earth assuming oblate spheroid
    defined by WGS84
    
    Input
    ---------
    lat: vector or latitudes in degrees  
    
    Output
    ----------
    r: vector of radius in meters
    
    Notes
    -----------
    WGS84: https://earth-info.nga.mil/GandG/publications/tr8350.2/tr8350.2-a/Chapter%203.pdf
    '''
    from numpy import deg2rad, sin, cos

    # define oblate spheroid from WGS84
    a = 6378137
    b = 6356752.3142
    e2 = 1 - (b**2/a**2)
    
    # convert from geodecic to geocentric
    # see equation 3-110 in WGS84
    lat = deg2rad(lat)
    lat_gc = np.arctan( (1-e2)*np.tan(lat) )

    # radius equation
    # see equation 3-107 in WGS84
    r = (
        (a * (1 - e2)**0.5) 
         / (1 - (e2 * np.cos(lat_gc)**2))**0.5 
        )

    return r


def area_grid(lat, lon):
    """
    Calculate the area of each grid cell
    Area is in square meters
    
    Input
    -----------
    lat: vector of latitude in degrees
    lon: vector of longitude in degrees
    
    Output
    -----------
    area: grid-cell area in square-meters with dimensions, [lat,lon]
    
    Notes
    -----------
    Based on the function in
    https://github.com/chadagreene/CDT/blob/master/cdt/cdtarea.m
    """
    from numpy import meshgrid, deg2rad, gradient, cos
    from xarray import DataArray

    xlon, ylat = meshgrid(lon, lat)
    R = earth_radius(ylat)

    dlat = deg2rad(gradient(ylat, axis=0))
    dlon = deg2rad(gradient(xlon, axis=1))

    dy = dlat * R
    dx = dlon * R * cos(deg2rad(ylat))

    area = dy * dx

    xda = DataArray(
        area,
        dims=["latitude", "longitude"],
        coords={"latitude": lat, "longitude": lon},
        attrs={
            "long_name": "area_per_pixel",
            "description": "area per pixel",
            "units": "m^2",
        },
    )
    return xda

grid_cell_area = area_grid(ds['latitude'].values, ds['longitude'].values)

standard_mean = ds['temperature'].mean(['latitude','longitude'])
standard_mean_smooth = standard_mean.rolling(time=12*5, center=True).mean().dropna("time")

total_area_of_earth = grid_cell_area.sum(['latitude','longitude'])
weighted_mean = ((ds['temperature'] * grid_cell_area) / total_area_of_earth).sum(['latitude','longitude'])

weighted_mean_smooth = weighted_mean.rolling(time=12*5, center=True).mean().dropna("time")

# --------------------------------------------
# setup the figure
# --------------------------------------------
fig = plt.figure(figsize=(6,6)) 
ax = fig.add_subplot(111)

# --------------------------------------------
# plot the data
# --------------------------------------------
#ax.plot(ds['time'], standard_mean, color=[1,0.8,0.8], label='Monthly (weighted mean)')
ax.plot(ds['time'], weighted_mean, color=[0.8,0.8,0.8], label='Monthly (weighted mean)')
ax.plot(weighted_mean_smooth['time'], weighted_mean_smooth, color='k', label='5 year smoothed (weighted mean)')
ax.plot(standard_mean_smooth['time'], standard_mean_smooth, color='r', label='5 year smoothed (standard mean)')

# --------------------------------------------
# plot legend
# --------------------------------------------
ax.legend(loc='upper left',frameon=False, fontsize=14)

# --------------------------------------------
# Range of axes
# --------------------------------------------
ax.set_ylim([-1, 1.5])
ax.set_xlim([np.datetime64('1850-01-15'), np.datetime64('2021-02-15')])

# --------------------------------------------
# Labels
# --------------------------------------------
ax.set_ylabel(f'degrees celsius ($^\circ$C)', fontsize=16)
ax.set_title(f'Temperature Anomaly', fontsize=18)

# --------------------------------------------
# Fontsize and rotation of axis labels
# --------------------------------------------
ax.tick_params(axis='x', rotation=45, labelsize=14)
ax.tick_params(axis='y', rotation=0, labelsize=14)

# --------------------------------------------
# Turn off the display of all ticks.
# --------------------------------------------
ax.tick_params(which='both',   # Options for both major and minor ticks
                top='off',     # turn off top ticks
                left='off',    # turn off left ticks
                right='off',   # turn off right ticks
                bottom='off',) # turn off bottom ticks


# --------------------------------------------
# Hide the right and top spines
# --------------------------------------------
ax.spines['right'].set_visible(False)
ax.spines['left'].set_visible(False)
ax.spines['top'].set_visible(False)
ax.spines['bottom'].set_visible(False)

# --------------------------------------------
# major / minor tick spaces
# --------------------------------------------
ax.minorticks_on()
ax.yaxis.set_minor_locator(AutoMinorLocator(5)) 
ax.xaxis.set_minor_locator(AutoMinorLocator(4))
ax.grid(axis='y', which='major', color=[0.8,0.8,0.8], linestyle='-')

# --------------------------------------------
# Only show ticks on the left and bottom spines
# --------------------------------------------
ax.yaxis.set_ticks_position('left')
ax.xaxis.set_ticks_position('bottom')

# --------------------------------------------
# Make axis square
# --------------------------------------------
x0, x1 = ax.get_xlim()
y0, y1 = ax.get_ylim()
ax.set_aspect(abs(x1-x0)/abs(y1-y0))

# --------------------------------------------
# Don't allow the axis to be on top of your data
# --------------------------------------------
ax.set_axisbelow(True)

# --------------------------------------------
# save figure
# --------------------------------------------
plt.savefig(f'./flux_time-series.pdf', 
                transparent = True, 
                bbox_inches = 'tight', 
                pad_inches = 0)

If you have questions on this post or found it helpful, please leave a comment.
[1] Rohde and Hausfather, The Berkeley Earth Land/Ocean Temperature Record (2020), ESSD
[2] Cowtan, Jacobs, Thorn, and Wilkinson, Statistical analysis of coverage error in simple global temperature estimators (2018), DSCS
Thank you for reading and supporting Medium writers