Skip to content

GWDA

This page documents 1 public symbols. Each entry includes its purpose, import path, full API docstring, and the maintained example that exercises it.

Conceptual guide

GWDA

Fit geographically weighted linear or quadratic discriminant analysis.

Property Value
Type class
Import from pygwrx.models import GWDA
Signature GWDA(kernel: 'str \| Any' = 'bisquare', bandwidth: 'float \| int \| str \| None' = 'cv', adaptive: 'bool' = True, quadratic: 'bool' = False, local_mean: 'bool' = True, local_cov: 'bool' = True, local_prior: 'bool' = True, prior: 'np.ndarray \| list[float] \| tuple[float, ...] \| None' = None, regularization: 'float' = 0.0, verbose: 'bool' = False) -> 'None'
Maintained example examples/models/10_gwda.py

GWDA

GWDA(
    kernel: str | Any = "bisquare",
    bandwidth: float | int | str | None = "cv",
    adaptive: bool = True,
    quadratic: bool = False,
    local_mean: bool = True,
    local_cov: bool = True,
    local_prior: bool = True,
    prior: (
        ndarray | list[float] | tuple[float, ...] | None
    ) = None,
    regularization: float = 0.0,
    verbose: bool = False,
)

Fit geographically weighted linear or quadratic discriminant analysis.

Parameters:

Name Type Description Default
kernel str | Any

Spatial kernel name or callable accepted by :func:pygwrx.core.kernels.get_kernel_function.

'bisquare'
bandwidth float | int | str | None

Positive fixed distance or, when adaptive=True, a positive integer neighbour count. None or "cv" selects a bandwidth by maximizing leave-one-out classification accuracy.

'cv'
adaptive bool

Whether bandwidth represents a nearest-neighbour count.

True
quadratic bool

Whether to use class-specific covariance matrices (WQDA). The default uses a locally pooled covariance matrix (WLDA).

False
local_mean bool

Whether class means vary geographically.

True
local_cov bool

Whether class covariance matrices vary geographically.

True
local_prior bool

Whether class prior probabilities vary geographically.

True
prior ndarray | list[float] | tuple[float, ...] | None

Optional fixed class priors in sorted class-label order. Values must be non-negative and sum to one.

None
regularization float

Explicit non-negative ridge added to covariance diagonals. The default performs the published unregularized method and raises when a required covariance is singular.

0.0
verbose bool

Whether to print a compact completion message.

False
Notes

For class :math:g at prediction location :math:u, pyGWRx computes a local weighted mean :math:\mu_g(u), an unbiased weighted covariance :math:\Sigma_g(u), and a local prior :math:\pi_g(u). The Gaussian discriminant cost is

.. math::

d_g(x,u) = \tfrac12\log|\Sigma_g(u)|
+ \tfrac12(x-\mu_g(u))^T\Sigma_g(u)^{-1}(x-\mu_g(u))
- \log\pi_g(u).

WLDA replaces the class-specific covariance by a locally pooled covariance. Classification selects the class with minimum cost.

GWmodel::gwda uses the same local statistics and classification ordering, but its published R source multiplies a matrix-norm term by the number of classes. pyGWRx uses the standard Gaussian log-determinant formula so that returned probabilities have a clear statistical meaning.

Source code in src/pygwrx/models/gwda.py
def __init__(
    self,
    kernel: str | Any = "bisquare",
    bandwidth: float | int | str | None = "cv",
    adaptive: bool = True,
    quadratic: bool = False,
    local_mean: bool = True,
    local_cov: bool = True,
    local_prior: bool = True,
    prior: np.ndarray | list[float] | tuple[float, ...] | None = None,
    regularization: float = 0.0,
    verbose: bool = False,
) -> None:
    for name, value in {
        "adaptive": adaptive,
        "quadratic": quadratic,
        "local_mean": local_mean,
        "local_cov": local_cov,
        "local_prior": local_prior,
        "verbose": verbose,
    }.items():
        if not isinstance(value, (bool, np.bool_)):
            raise TypeError(f"{name} must be boolean.")
    if isinstance(regularization, (bool, np.bool_)) or not isinstance(
        regularization, Real
    ):
        raise TypeError("regularization must be a non-negative real number.")
    if not np.isfinite(float(regularization)) or float(regularization) < 0:
        raise ValueError("regularization must be finite and non-negative.")

    self.kernel = kernel
    self.bandwidth = bandwidth
    self.adaptive = bool(adaptive)
    self.quadratic = bool(quadratic)
    self.local_mean = bool(local_mean)
    self.local_cov = bool(local_cov)
    self.local_prior = bool(local_prior)
    self.prior = prior
    self.regularization = float(regularization)
    self.verbose = bool(verbose)
    self._is_fitted = False
    self._clear_fit_state()

select_bandwidth

select_bandwidth(
    X: ndarray | DataFrame,
    y: ndarray | Series,
    coords: ndarray | DataFrame,
    *,
    bounds: tuple[float | int, float | int] | None = None
) -> float | int

Select a bandwidth by maximizing leave-one-out accuracy.

Parameters:

Name Type Description Default
X ndarray | DataFrame

Training feature matrix.

required
y ndarray | Series

Training class labels.

required
coords ndarray | DataFrame

Training coordinates.

required
bounds tuple[float | int, float | int] | None

Optional closed search interval. Adaptive bounds are integer neighbour counts; fixed bounds are positive distances.

None

Returns:

Type Description
float | int

Selected bandwidth.

Source code in src/pygwrx/models/gwda.py
def select_bandwidth(
    self,
    X: np.ndarray | pd.DataFrame,
    y: np.ndarray | pd.Series,
    coords: np.ndarray | pd.DataFrame,
    *,
    bounds: tuple[float | int, float | int] | None = None,
) -> float | int:
    """Select a bandwidth by maximizing leave-one-out accuracy.

    Args:
        X: Training feature matrix.
        y: Training class labels.
        coords: Training coordinates.
        bounds: Optional closed search interval. Adaptive bounds are integer
            neighbour counts; fixed bounds are positive distances.

    Returns:
        Selected bandwidth.
    """
    X_array, _ = self._validate_X(X)
    y_array = self._validate_y(y, X_array.shape[0])
    coords_array = validate_coords(coords)
    if coords_array.shape[0] != X_array.shape[0]:
        raise ValueError("X and coords must contain the same number of rows.")
    classes = np.unique(y_array)
    if classes.size < 2:
        raise ValueError("GWDA requires at least two classes.")
    fixed_prior = self._validate_prior(classes)
    self._validate_class_sizes(y_array, classes, X_array.shape[1])

    n_samples = X_array.shape[0]
    distance_matrix = compute_distance_matrix(coords_array, coords_array)
    if bounds is None:
        if self.adaptive:
            lower = max(2, min(n_samples, max(20, X_array.shape[1] + 2)))
            upper = n_samples
        else:
            maximum = float(np.max(distance_matrix))
            if maximum <= 0:
                raise ValueError(
                    "Coordinates must contain at least two distinct locations."
                )
            lower = maximum / 5000.0
            upper = maximum
    else:
        if len(bounds) != 2:
            raise ValueError("bounds must contain exactly two values.")
        lower = self._validate_bandwidth(bounds[0], n_samples)
        upper = self._validate_bandwidth(bounds[1], n_samples)
        if lower >= upper:
            raise ValueError(
                "The lower bandwidth bound must be smaller than the upper bound."
            )

    cache: dict[float | int, float] = {}

    def score(candidate: float | int) -> float:
        bandwidth = int(round(candidate)) if self.adaptive else float(candidate)
        bandwidth = self._validate_bandwidth(bandwidth, n_samples)
        if bandwidth not in cache:
            cache[bandwidth] = self._cv_accuracy(
                bandwidth,
                X_array,
                y_array,
                coords_array,
                classes,
                fixed_prior,
            )
        return cache[bandwidth]

    if self.adaptive and int(upper) - int(lower) <= 180:
        for candidate in range(int(lower), int(upper) + 1):
            score(candidate)
    else:
        optimization = minimize_scalar(
            lambda value: -score(value),
            bounds=(float(lower), float(upper)),
            method="bounded",
            options={"xatol": 1.0 if self.adaptive else 1e-5},
        )
        center = (
            int(round(optimization.x)) if self.adaptive else float(optimization.x)
        )
        if self.adaptive:
            for candidate in range(
                max(int(lower), center - 4), min(int(upper), center + 4) + 1
            ):
                score(candidate)
        else:
            score(center)

    best = min(cache, key=lambda candidate: (-cache[candidate], candidate))
    self.bandwidth_scores_ = tuple(sorted(cache.items(), key=lambda item: item[0]))
    return best

fit

fit(
    X: ndarray | DataFrame,
    y: ndarray | Series,
    coords: ndarray | DataFrame,
    X_pred: ndarray | DataFrame | None = None,
    coords_pred: ndarray | DataFrame | None = None,
    validate: bool = True,
) -> "GWDA"

Fit GWDA and optionally evaluate training or supplied prediction rows.

When prediction rows are omitted, validate=True performs leave-one-out classification and stores accuracy and a GWmodel-style confusion matrix. Supplying X_pred and coords_pred performs ordinary prediction while retaining the fitted training data for later calls to :meth:predict.

Source code in src/pygwrx/models/gwda.py
def fit(
    self,
    X: np.ndarray | pd.DataFrame,
    y: np.ndarray | pd.Series,
    coords: np.ndarray | pd.DataFrame,
    X_pred: np.ndarray | pd.DataFrame | None = None,
    coords_pred: np.ndarray | pd.DataFrame | None = None,
    validate: bool = True,
) -> "GWDA":
    """Fit GWDA and optionally evaluate training or supplied prediction rows.

    When prediction rows are omitted, ``validate=True`` performs leave-one-out
    classification and stores accuracy and a GWmodel-style confusion matrix.
    Supplying ``X_pred`` and ``coords_pred`` performs ordinary prediction while
    retaining the fitted training data for later calls to :meth:`predict`.
    """
    self._clear_fit_state()
    try:
        X_array, columns = self._validate_X(X)
        y_array = self._validate_y(y, X_array.shape[0])
        coords_array = validate_coords(coords)
        if coords_array.shape[0] != X_array.shape[0]:
            raise ValueError("X and coords must contain the same number of rows.")
        classes = np.unique(y_array)
        if classes.size < 2:
            raise ValueError("GWDA requires at least two classes.")
        self._validate_class_sizes(y_array, classes, X_array.shape[1])
        fixed_prior = self._validate_prior(classes)

        if self.bandwidth is None or (
            isinstance(self.bandwidth, str)
            and self.bandwidth.strip().lower() == "cv"
        ):
            bandwidth = self.select_bandwidth(X_array, y_array, coords_array)
        elif isinstance(self.bandwidth, str):
            raise ValueError("bandwidth must be numeric, None, or 'cv'.")
        else:
            bandwidth = self._validate_bandwidth(self.bandwidth, X_array.shape[0])

        if X_pred is None and coords_pred is None:
            X_eval = X_array
            coords_eval = coords_array
            leave_one_out = bool(validate)
            validation_mode = "leave-one-out" if validate else "training"
        elif X_pred is None or coords_pred is None:
            raise ValueError("X_pred and coords_pred must be supplied together.")
        else:
            X_eval, prediction_columns = self._validate_X(
                X_pred,
                expected_features=X_array.shape[1],
                name="X_pred",
            )
            if (
                columns is not None
                and prediction_columns is not None
                and columns != prediction_columns
            ):
                raise ValueError(
                    "X_pred DataFrame columns must match the training columns."
                )
            coords_eval = validate_coords(coords_pred)
            if coords_eval.shape[0] != X_eval.shape[0]:
                raise ValueError(
                    "X_pred and coords_pred must contain the same number of rows."
                )
            leave_one_out = False
            validation_mode = "prediction"

        result = self._evaluate(
            X_array,
            y_array,
            coords_array,
            X_eval,
            coords_eval,
            bandwidth,
            leave_one_out=leave_one_out,
            classes=classes,
            fixed_prior=fixed_prior,
        )

        self.classes_ = classes
        self.feature_names_in_ = columns
        self.n_features_in_ = X_array.shape[1]
        self.fixed_prior_ = None if fixed_prior is None else fixed_prior.copy()
        self.class_counts_ = np.asarray(
            [np.sum(y_array == label) for label in classes], dtype=int
        )
        self.bandwidth_ = bandwidth
        self.X_train_ = X_array.copy()
        self.y_train_ = y_array.copy()
        self.coords_train_ = coords_array.copy()
        self.class_means_ = result["means"]
        self.class_covariances_ = result["covariances"]
        # Backward-compatible spelling retained as a documented alias.
        self.class_covs_ = self.class_covariances_
        self.class_priors_ = result["priors"]
        self.pooled_covariances_ = result["pooled"]
        self.discriminant_scores_ = result["costs"]
        self.log_posteriors_ = self.discriminant_scores_
        self.predictions_ = result["predictions"]
        self.probabilities_ = result["probabilities"]
        self.entropy_ = result["entropy"]
        self.validation_mode_ = validation_mode
        if validate and X_pred is None:
            self.confusion_matrix_ = self._confusion_matrix(
                y_array, self.predictions_, classes
            )
            self.correct_ratio_ = float(np.mean(self.predictions_ == y_array))
        self._is_fitted = True
    except Exception:
        self._clear_fit_state()
        raise

    if self.verbose:
        print(
            "GWDA fit complete: "
            f"method={'WQDA' if self.quadratic else 'WLDA'}, "
            f"bandwidth={self.bandwidth_}, mode={self.validation_mode_}."
        )
    return self

predict

predict(
    X: ndarray | DataFrame, coords: ndarray | DataFrame
) -> np.ndarray

Predict class labels at new spatial locations without refitting.

Source code in src/pygwrx/models/gwda.py
def predict(
    self,
    X: np.ndarray | pd.DataFrame,
    coords: np.ndarray | pd.DataFrame,
) -> np.ndarray:
    """Predict class labels at new spatial locations without refitting."""
    return self._predict_result(X, coords)["predictions"]

predict_proba

predict_proba(
    X: ndarray | DataFrame, coords: ndarray | DataFrame
) -> np.ndarray

Return normalized Gaussian class probabilities at new locations.

Source code in src/pygwrx/models/gwda.py
def predict_proba(
    self,
    X: np.ndarray | pd.DataFrame,
    coords: np.ndarray | pd.DataFrame,
) -> np.ndarray:
    """Return normalized Gaussian class probabilities at new locations."""
    return self._predict_result(X, coords)["probabilities"]

predict_entropy

predict_entropy(
    X: ndarray | DataFrame, coords: ndarray | DataFrame
) -> np.ndarray

Return normalized Shannon classification entropy at new locations.

Source code in src/pygwrx/models/gwda.py
def predict_entropy(
    self,
    X: np.ndarray | pd.DataFrame,
    coords: np.ndarray | pd.DataFrame,
) -> np.ndarray:
    """Return normalized Shannon classification entropy at new locations."""
    return self._predict_result(X, coords)["entropy"]

get_entropy

get_entropy() -> np.ndarray

Return entropy stored by the most recent successful fit.

Source code in src/pygwrx/models/gwda.py
def get_entropy(self) -> np.ndarray:
    """Return entropy stored by the most recent successful fit."""
    self._check_fitted()
    return self.entropy_.copy()

summary

summary() -> str

Return a plain-text fitted-model summary.

Source code in src/pygwrx/models/gwda.py
def summary(self) -> str:
    """Return a plain-text fitted-model summary."""
    self._check_fitted()
    return format_summary(
        "GWDA Summary",
        {
            "n_samples": int(self.X_train_.shape[0]),
            "n_features": int(self.n_features_in_),
            "n_classes": int(self.classes_.size),
            "classes": self.classes_.copy(),
            "method": "WQDA" if self.quadratic else "WLDA",
            "bandwidth": self.bandwidth_,
            "adaptive": self.adaptive,
            "validation_mode": self.validation_mode_,
            "correct_ratio": self.correct_ratio_,
            "mean_entropy": float(np.mean(self.entropy_)),
        },
    )

Runnable examples used on this page

examples/models/10_gwda.py
# SPDX-FileCopyrightText: 2026 Jinghao Hu
# SPDX-License-Identifier: MIT

"""Fit geographically weighted discriminant analysis."""

# Allow this script to run directly from any working directory.
import sys
from pathlib import Path

_PROJECT_ROOT = Path(__file__).resolve().parents[2]
_EXAMPLES_ROOT = _PROJECT_ROOT / "examples"
_SRC_ROOT = _PROJECT_ROOT / "src"
for _path in (_SRC_ROOT, _EXAMPLES_ROOT):
    if str(_path) not in sys.path:
        sys.path.insert(0, str(_path))

from _common import classification_data

from pygwrx import GWDA

X, y, coords = classification_data()
model = GWDA(bandwidth=28, adaptive=True, quadratic=False).fit(X, y, coords)
print(model.summary())
print("classes=", model.classes_)
print("predictions=", model.predict(X.iloc[:5], coords.iloc[:5]))
print("probabilities=", model.predict_proba(X.iloc[:5], coords.iloc[:5]))