
0.1 Installation and the Package Map¶
Part: 0: Setup and Foundations (Chapter 0.1 of 4)
Focus: Install PySpatialStats and its extras, verify the environment, and map the package layout
Library: spatialstats core (plus optional extras)
Companion: Full chapter (all sections): ch00_setup_and_foundations.ipynb
1. Overview¶
Every later chapter — autocorrelation, regression, clustering, disease mapping, interpolation, sampling — silently relies on four things being right: the data are loaded correctly, the coordinate reference system is appropriate, distances are computed in sensible units, and the first map has been looked at with a critical eye. This first sub-chapter installs the toolkit and shows how the package is organised.
| Step | What we do |
|---|---|
| Install | Core package and optional extras (viz, bayes, libpysal, …) |
| Verify | Versions, smoke-check imports, see what is missing |
| Pattern | How a missing extra raises a named ImportError only when used |
| Map | Import graph and the shared SpatialResult interface |
Learning objectives (this notebook). Install and verify PySpatialStats; explain what happens when an optional dependency is missing; navigate which subpackage answers which question.
2. Python Setup¶
import ast, importlib, importlib.util, inspect, platform, sys, time, warnings
from pathlib import Path
import numpy as np
import pandas as pd
import geopandas as gpd
import matplotlib.pyplot as plt
from IPython.display import Markdown, display
warnings.filterwarnings("ignore", category=FutureWarning)
warnings.filterwarnings("ignore", category=DeprecationWarning)
def find_repo_root(start: Path = Path.cwd()) -> Path:
"""Walk up from ``start`` until the folder containing ``pyproject.toml`` and ``data/`` is found."""
for p in [start, *start.parents]:
if (p / "pyproject.toml").exists() and (p / "data").is_dir():
return p
raise FileNotFoundError("Run this notebook from inside the PySpatialStats repository.")
ROOT = find_repo_root()
DATA = ROOT / "data"
try:
import spatialstats
except ImportError: # package not pip-installed: fall back to the source tree
sys.path.insert(0, str(ROOT))
import spatialstats
plt.rcParams.update({
"figure.dpi": 100, "savefig.dpi": 100, "font.size": 10, "axes.titlesize": 11,
"axes.titleweight": "bold", "axes.spines.top": False, "axes.spines.right": False,
"axes.grid": True, "grid.alpha": 0.25, "legend.frameon": False,
})
C = {"blue": "#2b6cb0", "red": "#c0392b", "green": "#2f855a", "orange": "#dd6b20", "grey": "#718096", "purple": "#6b46c1"}
print(f"working directory : {Path.cwd().name}/")
print(f"repository data : {DATA.relative_to(ROOT)}/ ({len(list(DATA.iterdir()))} files)")
print(f"spatialstats : version {spatialstats.__version__}")
working directory : notebooks/ repository data : data/ (63 files) spatialstats : version 0.2.0
3. Installation and Environment¶
Installing¶
PySpatialStats keeps its core dependency list short (NumPy, SciPy, pandas, GeoPandas, Shapely, scikit-learn, joblib) and puts heavy or specialised libraries behind extras, so that you only install what you use:
pip install git+https://github.com/zia207/PySpatialStats.git
pip install "pyspatialstats[viz] @ git+https://github.com/zia207/PySpatialStats.git" # + matplotlib, folium, mapclassify (maps and plots)
pip install "pyspatialstats[bayes] @ git+https://github.com/zia207/PySpatialStats.git" # + PyMC, ArviZ (NUTS sampler for BYM, Chapter 5)
pip install "pyspatialstats[libpysal] @ git+https://github.com/zia207/PySpatialStats.git" # + libpysal (weights converters, cross-checks)
pip install "pyspatialstats[deep] @ git+https://github.com/zia207/PySpatialStats.git" # + PyTorch (GNNWR / GTNNWR, Chapter 6)
pip install "pyspatialstats[boosting] @ git+https://github.com/zia207/PySpatialStats.git" # + XGBoost, LightGBM (GW gradient boosting, Chapter 6)
pip install "pyspatialstats[all] @ git+https://github.com/zia207/PySpatialStats.git" # everything
or from a source checkout, in editable mode:
git clone https://github.com/zia207/PySpatialStats.git
cd PySpatialStats
pip install -e ".[all]"
Note. Imports and repository-root detection are in §2 Python Setup above.
4. Verifying the Installation¶
A reproducible analysis records what it ran on. The table below lists the versions of the core stack. Put a cell like this at the top of every analysis notebook.
def pkg_version(name):
try:
return importlib.import_module(name).__version__
except Exception:
return "—"
core_stack = ["numpy", "scipy", "pandas", "geopandas", "shapely", "pyproj", "sklearn", "joblib", "matplotlib"]
versions = pd.DataFrame(
{"package": ["python", "spatialstats"] + core_stack,
"version": [platform.python_version(), spatialstats.__version__] + [pkg_version(m) for m in core_stack]}
).set_index("package")
versions
| version | |
|---|---|
| package | |
| python | 3.11.14 |
| spatialstats | 0.2.0 |
| numpy | 2.2.6 |
| scipy | 1.15.3 |
| pandas | 2.3.3 |
| geopandas | 1.1.2 |
| shapely | 2.1.2 |
| pyproj | 3.7.2 |
| sklearn | 1.6.1 |
| joblib | 1.5.3 |
| matplotlib | 3.10.0 |
5. Optional Extras¶
The extras are declared in pyproject.toml. Rather than trusting a hand-written table, the next cell reads that
file and checks, for each optional package, whether it can be imported in the environment running this notebook.
try:
import tomllib
except ImportError: # Python < 3.11
import tomli as tomllib
meta = tomllib.loads((ROOT / "pyproject.toml").read_text())
extras = meta["project"]["optional-dependencies"]
IMPORT_NAME = {"scikit-learn": "sklearn", "torch": "torch", "xgboost": "xgboost", "lightgbm": "lightgbm",
"matplotlib": "matplotlib", "folium": "folium", "mapclassify": "mapclassify", "shap": "shap",
"networkx": "networkx", "libpysal": "libpysal", "pymc": "pymc", "arviz": "arviz"}
UNLOCKS = {
"viz": "maps, Moran scatterplot, LISA maps, natural-breaks classification (Chapters 0, 2, 4)",
"bayes": "NUTS sampler and BYM2 for disease mapping (Chapter 5)",
"libpysal": "weights converters; cross-checks against PySAL (Chapter 2)",
"deep": "GNNWR / GTNNWR neural GW models (Chapter 6)",
"boosting": "GW XGBoost / LightGBM (Chapter 6)",
"network": "network-distance GWR (Chapter 6)",
"shap": "SHAP importance for GW ensembles (Chapter 6)",
}
rows = []
for extra, reqs in extras.items():
if extra in ("all", "dev"):
continue
for req in reqs:
name = req.split(">")[0].split("=")[0].split("<")[0].strip()
mod = IMPORT_NAME.get(name, name)
rows.append({"extra": extra, "package": name,
"installed": importlib.util.find_spec(mod) is not None,
"unlocks": UNLOCKS.get(extra, "")})
extras_df = pd.DataFrame(rows)
extras_df["installed"] = extras_df["installed"].map({True: "yes", False: "no"})
extras_df
| extra | package | installed | unlocks | |
|---|---|---|---|---|
| 0 | deep | torch | yes | GNNWR / GTNNWR neural GW models (Chapter 6) |
| 1 | boosting | xgboost | yes | GW XGBoost / LightGBM (Chapter 6) |
| 2 | boosting | lightgbm | yes | GW XGBoost / LightGBM (Chapter 6) |
| 3 | viz | matplotlib | yes | maps, Moran scatterplot, LISA maps, natural-br... |
| 4 | viz | folium | yes | maps, Moran scatterplot, LISA maps, natural-br... |
| 5 | viz | mapclassify | yes | maps, Moran scatterplot, LISA maps, natural-br... |
| 6 | shap | shap | yes | SHAP importance for GW ensembles (Chapter 6) |
| 7 | network | networkx | yes | network-distance GWR (Chapter 6) |
| 8 | libpysal | libpysal | yes | weights converters; cross-checks against PySAL... |
| 9 | bayes | pymc | yes | NUTS sampler and BYM2 for disease mapping (Cha... |
| 10 | bayes | arviz | yes | NUTS sampler and BYM2 for disease mapping (Cha... |
6. The Optional-Dependency Pattern¶
A missing extra must never break import spatialstats, and must fail clearly and only when the feature that needs
it is used. The package follows one pattern everywhere: import lazily inside the function that needs the library,
and raise an ImportError that names the extra to install. You can see it in action with the neural-network model
(needs PyTorch) and with the PyMC backend of the Bayesian module:
from spatialstats.gwmodel import GNNWR
from spatialstats.bayes import pymc_available
print("PyMC available :", pymc_available())
print("PyTorch present:", importlib.util.find_spec("torch") is not None)
if importlib.util.find_spec("torch") is None:
model = GNNWR() # constructing the object is fine ...
print("constructed :", type(model).__name__)
try:
rng = np.random.default_rng(0)
model.fit(rng.random((30, 2)), rng.random(30), rng.random((30, 2))) # ... using it is not
except ImportError as err:
print("fit() raised :", f"ImportError: {err}")
else:
print("PyTorch is installed here, so GNNWR would train; nothing to demonstrate.")
PyMC available : True PyTorch present: True PyTorch is installed here, so GNNWR would train; nothing to demonstrate.
Self-test. Once installed, run the package's smoke tests from the repository root:
PYTHONPATH=. python tests/smoke_all_modules.py # quick, dependency-free checks python -m pytest tests/test_spatial_modules.py -q # statistical validation of the spatial modulesTests that compare against reference libraries (
spreg,pykrige,pointpats,esda, PyMC) are skipped automatically when those libraries are absent.
7. The Package Map¶
Subpackages at a glance¶
| Subpackage | Answers the question … |
|---|---|
core |
How do I hold spatial data, reproject it, and measure distances? |
weights |
Who is whose neighbour? (contiguity, k-nearest, distance band, kernel) |
esda |
Are values spatially autocorrelated? (Moran, Geary, Getis–Ord, join counts) |
viz |
How do I map results? (choropleth, Moran scatterplot, LISA map) |
datasets |
What data can I practise on? |
pointpattern · regression · cluster · bayes · interpolate · sampling |
The topical modules of Chapters 1, 3, 4, 5, 7, 8 |
gwmodel |
Do relationships vary over space? (GWR, MGWR, GW machine learning, GNNWR, …) |
spacetime |
Planned — space–time methods |
Rather than draw the architecture by hand, the next cell derives it from the source code: it parses every module, finds which subpackages import which, and separates eager imports (executed when the package loads) from lazy ones (inside a function, executed only when used). Eager edges define the layering; lazy edges are the optional couplings.
PKG = ROOT / "spatialstats"
def import_graph(pkg_dir: Path):
"""Return (subpackages, {(src, dst): 'eager' | 'lazy'}) from the source code of the package."""
subs = sorted(p.name for p in pkg_dir.iterdir() if p.is_dir() and (p / "__init__.py").exists())
edges = {}
for sub in subs:
for f in (pkg_dir / sub).rglob("*.py"):
tree = ast.parse(f.read_text())
top_level = {id(n) for n in tree.body}
for node in ast.walk(tree):
targets = []
if isinstance(node, ast.ImportFrom) and node.module and node.level == 0:
parts = node.module.split(".")
if parts[0] == "spatialstats":
targets = [parts[1]] if len(parts) > 1 else [a.name for a in node.names]
elif isinstance(node, ast.Import):
targets = [a.name.split(".")[1] for a in node.names
if a.name.startswith("spatialstats.") and a.name.count(".") >= 1]
for t in targets:
if t in subs and t != sub:
kind = "eager" if id(node) in top_level else "lazy"
if edges.get((sub, t)) != "eager":
edges[(sub, t)] = kind
return subs, edges
subs, edges = import_graph(PKG)
# layer = 1 + the deepest eager dependency (0 = depends on nothing)
eager_deps = {s: [d for (a, d), k in edges.items() if a == s and k == "eager"] for s in subs}
layer = {}
def depth(s, seen=()):
if s in layer:
return layer[s]
if s in seen: # defensive: an eager cycle would make the layering meaningless
return 0
layer[s] = 0 if not eager_deps[s] else 1 + max(depth(d, seen + (s,)) for d in eager_deps[s])
return layer[s]
for s in subs:
depth(s)
summary = pd.DataFrame({
"public names": [len(getattr(spatialstats, s).__all__) for s in subs],
"status": ["ready" if len(getattr(spatialstats, s).__all__) else "planned" for s in subs],
"layer": [layer[s] for s in subs],
"imports (eager)": [", ".join(sorted(eager_deps[s])) or "—" for s in subs],
"imports (lazy)": [", ".join(sorted(d for (a, d), k in edges.items() if a == s and k == "lazy")) or "—" for s in subs],
}, index=subs).sort_values(["layer", "public names"], ascending=[True, False])
summary
from matplotlib.patches import FancyBboxPatch
layers = {}
for s in subs:
layers.setdefault(layer[s], []).append(s)
pos = {}
for L, names in layers.items():
names = sorted(names)
for i, s in enumerate(names):
pos[s] = ((i + 1) / (len(names) + 1), L)
fig, ax = plt.subplots(figsize=(11, 5.2))
for (a, b), kind in edges.items():
(x0, y0), (x1, y1) = pos[a], pos[b]
up = y1 > y0 # lazy imports may point "up" the layering
ax.annotate("", xy=(x1, y1 - 0.16 if up else y1 + 0.16), xytext=(x0, y0 + 0.16 if up else y0 - 0.16),
arrowprops=dict(arrowstyle="->", color=C["grey"] if kind == "eager" else C["orange"],
lw=1.1, ls="-" if kind == "eager" else "--", alpha=0.75,
connectionstyle="arc3,rad=0.06"))
for s, (x, y) in pos.items():
ready = summary.loc[s, "status"] == "ready"
ax.add_patch(FancyBboxPatch((x - 0.055, y - 0.15), 0.11, 0.30, boxstyle="round,pad=0.005,rounding_size=0.02",
fc=C["blue"] if ready else "#e2e8f0", ec="white", lw=1.5))
ax.text(x, y, s, ha="center", va="center", fontsize=9, fontweight="bold",
color="white" if ready else C["grey"])
ax.set_xlim(0, 1); ax.set_ylim(-0.5, max(layers) + 0.5); ax.axis("off")
ax.plot([], [], color=C["grey"], label="eager import (needed at load time)")
ax.plot([], [], color=C["orange"], ls="--", label="lazy import (only when the feature is used)")
ax.legend(loc="lower right", fontsize=9)
ax.set_title("Package architecture, derived from the source code (arrows point to what a module builds on)")
plt.tight_layout(); plt.show()
Two design points follow from the graph:
coresits at the bottom and depends on nothing: distance and CRS helpers are usable on their own.- Modules that draw pictures (
esda,regression, …) reachvizlazily. That is whyimport spatialstatsworks without matplotlib, and why plotting methods raise a helpfulImportErrorinstead of breaking the import.
A common result object¶
Analyses in different subpackages return objects that share one small interface, SpatialResult, so that once you have
learned to read one result you can read them all:
| Member | Meaning |
|---|---|
result.summary() |
human-readable text report |
result.stats |
dictionary of the scalar statistics |
result["name"], result.get("name", default) |
direct access to one statistic |
result.to_frame() |
a tidy DataFrame (one row per observation when the result is per-area, otherwise a single row) |
result.plot() |
a default figure (needs the viz extra) |
from spatialstats.weights import Queen
from spatialstats.esda import Moran
from spatialstats.pointpattern import PointPattern, Window, simulate_csr, quadrat_test
ne = gpd.read_file(DATA / "diabetes_northeast.shp") # 217 counties (loaded properly in section 0.4)
moran = Moran(ne["Dia_pct"], Queen(ne), permutations=199, seed=1) # preview of Chapter 2
quad = quadrat_test(simulate_csr(n=200, window=Window.from_bounds(0, 0, 1, 1), seed=1), 4, 4) # preview of Chapter 1
for res in (moran, quad):
print(f"{type(res).__name__:14s} | name = {res.name!r:22s} | keys = {list(res.stats)[:5]} ...")
print()
print(moran.summary())
print()
print("moran['I'] =", round(moran["I"], 4), "| moran.get('p_value') =", moran.get("p_value"), "| quad.get('nope', 'default') =", quad.get("nope", "default"))
moran.to_frame().round(4)
8. Summary and Where Next¶
- Environment. Extras keep the core small; a missing extra raises an
ImportErrornaming what to install, only when the feature is used. Record versions with your results. - Structure.
coreis the bottom layer; the analysis modules share aSpatialResultinterface (summary,stats,to_frame,plot).
Next: 0.2 Spatial Data Types and Containers —
geostatistical fields, lattice data, point patterns, and the SpatialData container.
Full single-file chapter: ch00_setup_and_foundations.ipynb.