Getis-Ord Gi* vs Local Moran's I for Hot Spots
TL;DR: Use esda.getisord.G_Local(y, w, star=True) when the question is where is the intensity high or low — Gi* answers it directly with a signed z-score. Use esda.moran.Moran_Local(y, w) when spatial outliers matter, because Local Moran’s I splits significant units into high-high, low-low, high-low and low-high, and Gi* structurally cannot see the last two. Run both on one weights object; disagreement is information, not error.
Why This Matters
Both statistics get called “hot-spot analysis” and both produce a map of significant units, which makes them look interchangeable until a stakeholder asks why an obviously extreme location is not highlighted. They are testing different null hypotheses. Choosing the wrong one either hides the anomalies you were hired to find or clutters an intensity map with single-unit spikes. This choice sits inside hot spot analysis and rests on the neighbour definitions covered in spatial weight matrices.
The formulas make the difference plain. Gi* compares a neighbourhood sum against the global total:
with including itself. Local Moran’s I instead multiplies the focal deviation by the neighbouring deviations:
A sum cannot change sign when one term is high and the rest are low; a product can. That single algebraic fact is the whole of the difference.
Environment and Version Pinning
pip install "geopandas>=1.0" "libpysal>=4.9" "esda>=2.5" "numpy>=1.24" "pandas>=2.0"
import geopandas as gpd
import numpy as np
import pandas as pd
import libpysal
from esda.getisord import G_Local
from esda.moran import Moran_Local
from shapely.geometry import box
Step-by-Step Implementation
1. Build one weights object for both statistics
Any divergence between the two maps must be attributable to the statistics, not the neighbourhoods. Build w once.
cells = [box(x, y, x + 1, y + 1) for y in range(20) for x in range(20)]
gdf = gpd.GeoDataFrame(geometry=cells, crs="EPSG:32618").reset_index(drop=True)
rng = np.random.default_rng(11)
cx = gdf.geometry.centroid.x.to_numpy()
cy = gdf.geometry.centroid.y.to_numpy()
# Two broad clusters plus deliberately planted single-cell spikes.
field = (np.exp(-((cx - 5) ** 2 + (cy - 5) ** 2) / 12)
+ np.exp(-((cx - 15) ** 2 + (cy - 15) ** 2) / 12))
gdf["rate"] = 20 + 40 * field + rng.normal(0, 2.0, len(gdf))
gdf.loc[[87, 152, 233], "rate"] += 45 # high values in low neighbourhoods
gdf.loc[[46, 118], "rate"] -= 28 # low values in high neighbourhoods
w = libpysal.weights.Queen.from_dataframe(gdf, use_index=False)
w.transform = "R"
y = gdf["rate"].to_numpy()
2. Run Gi* with the self-inclusive star
gi = G_Local(y, w, star=True, permutations=999, seed=7)
gdf["gi_z"] = gi.Zs
gdf["gi_p"] = gi.p_sim
ALPHA = 0.05
gdf["gi_class"] = "not significant"
gdf.loc[(gdf.gi_p < ALPHA) & (gdf.gi_z > 0), "gi_class"] = "hot spot"
gdf.loc[(gdf.gi_p < ALPHA) & (gdf.gi_z < 0), "gi_class"] = "cold spot"
print(gdf["gi_class"].value_counts())
not significant 312
hot spot 47
cold spot 41
3. Run Local Moran’s I on the identical inputs
lm = Moran_Local(y, w, permutations=999, seed=7)
LABELS = {0: "not significant", 1: "high-high", 2: "low-high",
3: "low-low", 4: "high-low"}
q = np.where(lm.p_sim < ALPHA, lm.q, 0)
gdf["lisa_class"] = pd.Series(q).map(LABELS).to_numpy()
print(gdf["lisa_class"].value_counts())
not significant 306
high-high 44
low-low 39
high-low 7
low-high 4
4. Cross-tabulate to find the disagreements
xtab = pd.crosstab(gdf["gi_class"], gdf["lisa_class"])
print(xtab.to_string())
lisa_class high-high high-low low-high low-low not significant
gi_class
cold spot 0 0 3 38 0
hot spot 43 1 0 0 3
not significant 1 6 1 1 304
The interesting cell is the bottom row: six units that Local Moran’s I flags as high-low outliers, Gi* leaves unclassified. Those are the deliberately planted spikes. Gi* is not wrong — a lone high value genuinely does not make its neighbourhood’s total unusual — but if the analysis is about detecting anomalies, its silence is a failure to answer the question asked.
Note also the three units where Gi* says hot and Local Moran’s I says nothing: neighbourhoods with consistently mild elevation that sum to a significant total without any single deviation being large enough to produce a significant product.
Interpreting the Output
gi.Zs is a signed z-score: positive means the neighbourhood sums higher than a random neighbourhood of the same size would, negative means lower. It has no outlier category by construction, so the map divides into hot, cold and unremarkable.
lm.q is a quadrant code, and it is only meaningful where lm.p_sim is significant — which is why the masking step above sets non-significant units to 0 before mapping labels. Quadrants 1 and 3 are clusters; 2 and 4 are spatial outliers, the units whose value opposes their surroundings.
Read the two together. Agreement on the clusters is expected and reassuring. Where Gi* is silent and Local Moran’s I flags an outlier, you have found a place that differs from its context — often a data error, a boundary artefact, or a genuinely singular site. Where Gi* flags a hot spot and Local Moran’s I does not, you have found a broad, gentle elevation rather than a sharp cluster. Neither pattern is noise; they are different phenomena.
Critical Best Practices
Use star=True for hot-spot maps
Without the star, the focal unit is excluded from its own neighbourhood sum, and the map answers “how unusual are my neighbours” rather than “how unusual is this place”. The non-star form has legitimate uses in spillover analysis, but a hot-spot map built on it will look subtly wrong to anyone who checks a specific location.
Correct for multiple comparisons, and say which correction
Testing 400 units at yields about 20 false positives before any real signal. Apply an FDR correction, or tighten to 0.01. The extent of the coloured area on the map changes materially with this choice, so it belongs in the caption, not in a methods appendix.
Keep the weights identical, and justify them once
Both statistics inherit their notion of “local” entirely from w. A distance band of 2 km and a k-nearest of 8 will produce different maps from the same data with either statistic. Choose the neighbourhood from the process scale — see spatial weight matrices — and apply it to both.
Standardise rates before comparing regions of unequal population
Both statistics are sensitive to the variance instability of raw rates computed over small denominators. A cell with three cases out of five people will dominate any neighbourhood it sits in. Use an empirical Bayes smoothed rate, or restrict the analysis to units meeting a minimum denominator.
Report both when the audience is mixed
The cross-tabulation above takes one line of code and pre-empts the question “why isn’t this location highlighted”. Publishing both classifications, with the disagreements named, is more defensible than picking one and hoping nobody checks.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Gi* highlights nothing at all | Variable has little spatial structure, or weights are too broad | Check global Moran’s I first; shrink the distance band or reduce k |
| Every unit is significant | No multiple-comparison correction with many units | Apply FDR, or use permutations=9999 with |
lm.q values look wrong |
Quadrants read without masking on p_sim |
Set non-significant units to 0 before mapping labels |
| Hot spots trace population density, not the phenomenon | Raw counts used instead of rates | Convert to a rate, then smooth small-denominator units |
G_Local raises on islands in w |
Units with no neighbours | Rebuild with KNN, or drop islands and note the exclusion |
| Results shift between runs | Permutation seed not fixed | Pass seed= to both estimators for reproducible pseudo p-values |
Next Steps
For the full Gi* workflow including mapping and rate smoothing, see Getis-Ord Gi* hot spot analysis in Python; for the cluster-and-outlier map in depth, see computing Local Moran’s I (LISA) in Python. When hot spots need to be tracked over time rather than mapped once, continue to emerging hot spot analysis over time in Python.
Frequently Asked Questions
Why does Getis-Ord Gi never identify spatial outliers?*
Gi* is a ratio of a neighbourhood sum to the global sum, so it only measures whether a neighbourhood totals more or less than expected. A single very high value surrounded by low values produces a middling sum, and Gi* returns an unremarkable z-score. Local Moran’s I multiplies the unit’s own deviation by its neighbours’ average deviation, so opposite signs give a negative product that is flagged as a high-low or low-high outlier. Detecting outliers requires that cross-product; a sum cannot do it.
Should star be True or False in G_Local?
Use star=True for hot-spot mapping. The star variant includes the unit’s own value in its neighbourhood sum, which is what makes Gi* a statement about a place including itself rather than about its surroundings only. The non-star Gi excludes the focal value and answers a different question — how unusual are my neighbours — which is rarely what a hot-spot map is meant to show.
Do I need to correct for multiple comparisons?
Yes, for any map with more than a handful of units. Both statistics test every unit, so at a 0.05 threshold roughly one in twenty units will look significant by chance alone. Apply a false discovery rate correction to the pseudo p-values, or use the conditional permutation p-values with a stricter threshold such as 0.01. Report which correction was used, because the visible extent of hot spots depends heavily on it.
Related
- Getis-Ord Gi* Hot Spot Analysis in Python — the complete Gi* workflow end to end
- Computing Local Moran’s I (LISA) in Python — the cluster-and-outlier classification in depth
- Spatial Weight Matrices — the shared neighbourhood definition both statistics depend on
← Back to Hot Spot Analysis