Banner

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


Table of Contents¶

  1. Overview

  2. Python Setup

  3. Installation and Environment

  4. Verifying the Installation

  5. Optional Extras

  6. The Optional-Dependency Pattern

  7. The Package Map

  8. Summary and Where Next


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¶

In [1]:
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.

In [2]:
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
Out[2]:
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.

In [3]:
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
Out[3]:
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:

In [4]:
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 modules

Tests 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.

In [ ]:
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
In [ ]:
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:

  • core sits at the bottom and depends on nothing: distance and CRS helpers are usable on their own.
  • Modules that draw pictures (esda, regression, …) reach viz lazily. That is why import spatialstats works without matplotlib, and why plotting methods raise a helpful ImportError instead 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)
In [ ]:
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 ImportError naming what to install, only when the feature is used. Record versions with your results.
  • Structure. core is the bottom layer; the analysis modules share a SpatialResult interface (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.