From c66777911b54f9cb9a3c7047d14104fd6881e41c Mon Sep 17 00:00:00 2001 From: yigong Date: Wed, 27 May 2026 21:54:40 +0800 Subject: [PATCH] feat: add GTWR model Python interface with C++ binding, tests, and docs --- doc/index.rst | 1 + doc/models.rst | 1 + doc/models/gtwr.rst | 131 +++++++++++++++++++++++++++ doc/pygwmodel.rst | 8 ++ doc/quickstart.rst | 1 + src/CMakeLists.txt | 2 +- src/gtwr.cpp | 139 +++++++++++++++++++++++++++++ src/pygwmodel/__init__.py | 3 +- src/pygwmodel/gtwr.py | 152 ++++++++++++++++++++++++++++++++ src/pygwmodel/spatial_weight.py | 37 ++++++-- src/regression.cpp | 2 + src/spatial_weight.cpp | 13 +++ test/test_gtwr.py | 102 +++++++++++++++++++++ 13 files changed, 584 insertions(+), 8 deletions(-) create mode 100644 doc/models/gtwr.rst create mode 100644 src/gtwr.cpp create mode 100644 src/pygwmodel/gtwr.py create mode 100644 test/test_gtwr.py diff --git a/doc/index.rst b/doc/index.rst index bdcb0bf..0e4e736 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -15,6 +15,7 @@ Implemented Models * **GWRBasic** — Basic Geographically Weighted Regression with a single bandwidth. * **GWRMultiscale** — Multiscale GWR (MGWR) with per-variable bandwidths and backfitting algorithm. +* **GTWR** — Geographically and Temporally Weighted Regression. * **GWAverage** — Geographically Weighted Summary Statistics (mean, std dev, etc.). * **GWCorrelation** — Geographically Weighted Correlation coefficients. diff --git a/doc/models.rst b/doc/models.rst index 6355b17..840cf3a 100644 --- a/doc/models.rst +++ b/doc/models.rst @@ -6,4 +6,5 @@ Regression Models models/gwr models/gwr_multiscale + models/gtwr models/gwss diff --git a/doc/models/gtwr.rst b/doc/models/gtwr.rst new file mode 100644 index 0000000..9024bd1 --- /dev/null +++ b/doc/models/gtwr.rst @@ -0,0 +1,131 @@ +Geographically and Temporally Weighted Regression (GTWR) +========================================================= + +.. _gtwr-overview: + +Model Overview +-------------- + +Geographically and Temporally Weighted Regression (GTWR) extends GWR by +incorporating temporal information via a spatio-temporal distance metric. +GTWR assigns a spatio-temporal ratio parameter :math:`\lambda \in [0, 1]` +that balances the influence of spatial proximity versus temporal proximity +on the weighting scheme. + +Key features: + +- Supports automatic bandwidth selection via CV or AIC criteria. +- Supports OpenMP parallel computation. +- Provides standard errors and diagnostic statistics. + +.. _gtwr-constructor: + +Constructor +----------- + +.. code-block:: python + + from pygwmodel import GTWR, BandwidthWeight + from pygwmodel.spatial_weight import CRSSTDistance + + algorithm = GTWR( + data, # GeoDataFrame + 'PURCHASE', # dependent variable + ['FLOORSZ', 'UNEMPLOY', 'PROF'], # independent variables + times='TIME', # temporal column + weight=BandwidthWeight(36.0, adaptive=True), + distance=CRSSTDistance(lambda_=0.5) + ) + +Parameters: + +.. list-table:: + :header-rows: 1 + :widths: 20 20 60 + + * - Parameter + - Type + - Description + * - ``sdf`` + - ``GeoDataFrame`` + - Input data with geometry + * - ``depen_var`` + - ``str`` + - Dependent variable column name + * - ``indep_vars`` + - ``List[str]`` + - Independent variable column names + * - ``times`` + - ``str`` + - Column for temporal stamps + * - ``weight`` + - ``BandwidthWeight`` + - Bandwidth configuration + * - ``distance`` + - ``CRSSTDistance`` + - Spatio-temporal distance (default CRSSTDistance) + * - ``has_intercept`` + - ``bool`` + - Include intercept term (default True) + +.. _gtwr-examples: + +Code Examples +------------- + +Basic Fit +~~~~~~~~~ + +.. code-block:: python + + from pygwmodel import GTWR, BandwidthWeight + from pygwmodel.spatial_weight import CRSSTDistance + + algorithm = GTWR( + data, 'PURCHASE', ['FLOORSZ', 'UNEMPLOY', 'PROF'], + times='TIME', + weight=BandwidthWeight(36.0, adaptive=True), + distance=CRSSTDistance(lambda_=0.5) + ).fit() + + print(algorithm.diagnostic) + # {'RSS': ..., 'AIC': ..., 'AICc': ..., 'RSquare': ...} + + print(algorithm.result_layer.columns) + # Index(['Intercept', 'FLOORSZ', 'UNEMPLOY', 'PROF', + # 'Intercept_SE', 'FLOORSZ_SE', 'UNEMPLOY_SE', 'PROF_SE', + # 'fitted']) + +Bandwidth Auto-Selection +~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + algorithm = GTWR( + data, 'PURCHASE', ['FLOORSZ', 'UNEMPLOY', 'PROF'], + times='TIME', + weight=BandwidthWeight(36.0, adaptive=True), + distance=CRSSTDistance(lambda_=0.5) + ).fit(optimize_bandwidth=GTWR.BandwidthSelectionCriterionType.CV) + + print(f"Optimal bandwidth: {algorithm.weight.bandwidth}") + +Prediction +~~~~~~~~~~ + +.. code-block:: python + + prediction = algorithm.predict(data) + print(prediction.columns) + # Index(['Intercept', 'FLOORSZ', 'UNEMPLOY', 'PROF', + # 'y_hat', 'residual']) + +Parallel Execution +~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + from pygwmodel import GTWR, ParallelType + + algorithm = GTWR(..., weight=...) + algorithm.enable_parallel(ParallelType.OpenMP, threads=4).fit() diff --git a/doc/pygwmodel.rst b/doc/pygwmodel.rst index 047f20a..04d9e16 100644 --- a/doc/pygwmodel.rst +++ b/doc/pygwmodel.rst @@ -20,6 +20,14 @@ pygwmodel.gwr_multiscale module :undoc-members: :show-inheritance: +pygwmodel.gtwr module +--------------------- + +.. automodule:: pygwmodel.gtwr + :members: + :undoc-members: + :show-inheritance: + pygwmodel.gwss module --------------------- diff --git a/doc/quickstart.rst b/doc/quickstart.rst index 92d3602..a0bbf56 100644 --- a/doc/quickstart.rst +++ b/doc/quickstart.rst @@ -15,6 +15,7 @@ Currently implemented models: - **Geographically Weighted Regression (GWR)** — :class:`~pygwmodel.gwr_basic.GWRBasic` - **Multiscale GWR (MGWR)** — :class:`~pygwmodel.gwr_multiscale.GWRMultiscale` +- **Geographically and Temporally Weighted Regression (GTWR)** — :class:`~pygwmodel.gtwr.GTWR` - **GW Average** — :class:`~pygwmodel.gwss.GWAverage` - **GW Correlation** — :class:`~pygwmodel.gwss.GWCorrelation` diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index ce7b6e9..424635a 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -27,7 +27,7 @@ target_link_libraries(_parallel PRIVATE gwmodel) nanobind_add_module(_spatial_weight STABLE_ABI spatial_weight.cpp) target_link_libraries(_spatial_weight PRIVATE gwmodel) -nanobind_add_module(_regression STABLE_ABI common.hpp parallel.hpp base.cpp gwr_basic.cpp gwr_multiscale.cpp regression.cpp) +nanobind_add_module(_regression STABLE_ABI common.hpp parallel.hpp base.cpp gwr_basic.cpp gwr_multiscale.cpp gtwr.cpp regression.cpp) target_link_libraries(_regression PRIVATE gwmodel) nanobind_add_module(_analysis STABLE_ABI common.hpp base.cpp parallel.hpp analysis.cpp gwss.cpp) diff --git a/src/gtwr.cpp b/src/gtwr.cpp new file mode 100644 index 0000000..133c426 --- /dev/null +++ b/src/gtwr.cpp @@ -0,0 +1,139 @@ +#include +#include +#include +#include +#include +#include +#include "common.hpp" +#include "parallel.hpp" + +namespace nb = nanobind; + +struct GTWRHelper : gwm::GTWR +{ + void prepare_st_distance() + { + mStdistance = mSpatialWeight.distance(); + } + + void set_times_vec(const arma::vec ×) + { + vTimes = times; + } +}; + +void init_gtwr(nb::module_& m) +{ + nb::class_ _GTWR(m, "_GTWR"); + + nb::enum_(_GTWR, "BandwidthSelectionCriterionType") + .value("AIC", gwm::GTWR::BandwidthSelectionCriterionType::AIC) + .value("CV", gwm::GTWR::BandwidthSelectionCriterionType::CV) + .export_values(); + + _GTWR + .def(nb::init<>()) + .def( + "prepare_st_distance", + >WRHelper::prepare_st_distance + ) + .def_prop_ro( + "select_bandwidth_enabled", + &gwm::GTWR::isAutoselectBandwidth + ) + .def( + "set_select_bandwidth", + [](GTWRHelper &instance, bool enable, int criterion) + { + instance.setIsAutoselectBandwidth(enable); + instance.setBandwidthSelectionCriterion( + (gwm::GTWR::BandwidthSelectionCriterionType)criterion); + } + ) + .def( + "set_select_lambda", + [](GTWRHelper &instance, bool enable) + { instance.setIsAutoselectLambda(enable); } + ) + .def( + "set_select_lambda_bw", + [](GTWRHelper &instance, bool enable) + { instance.setIsAutoselectLambdaBw(enable); } + ) + .def_prop_rw( + "has_hat_matrix", + &gwm::GTWR::hasHatMatrix, + &gwm::GTWR::setHasHatMatrix + ) + .def_prop_ro( + "bandwidth_criterions", + &gwm::GTWR::bandwidthSelectionCriterionList + ) + .def( + "set_times_vec", + [](GTWRHelper &instance, const arma::vec ×) + { + instance.set_times_vec(times); + } + ) + .def( + "fit", + [](GTWRHelper &instance){ instance.fit(); } + ) + .def( + "predict", + [](GTWRHelper &instance, arma::mat locs){ return instance.predict(locs); }, + nb::rv_policy::move + ) + .def_prop_ro( + "fitted", + [](GTWRHelper &instance){ + return instance.Fitted(instance.independentVariables(), instance.betas()); + }, + nb::rv_policy::move + ) + .def( + "predict", + [](gwm::GTWR &instance, arma::mat locs){ return instance.predict(locs); }, + nb::rv_policy::move + ) + .def_prop_ro( + "betasSE", + &gwm::GTWR::betasSE, + nb::rv_policy::move + ) + .def_prop_ro( + "fitted", + [](gwm::GTWR &instance){ + return instance.Fitted(instance.independentVariables(), instance.betas()); + }, + nb::rv_policy::move + ) + .def_prop_ro( + "s_hat", + &gwm::GTWR::sHat, + nb::rv_policy::move + ) + .def_prop_ro( + "q_diag", + &gwm::GTWR::qDiag, + nb::rv_policy::move + ) + .def_prop_ro( + "s", + &gwm::GTWR::s, + nb::rv_policy::move + ) + .def_prop_ro( + "lambda_", + &gwm::GTWR::getLambda + ) + .def_prop_ro( + "angle", + &gwm::GTWR::getAngle + ) + ; + + def_parallel_info(_GTWR); + def_parallel_openmp(_GTWR); +} diff --git a/src/pygwmodel/__init__.py b/src/pygwmodel/__init__.py index e7f91a5..a7e95c9 100644 --- a/src/pygwmodel/__init__.py +++ b/src/pygwmodel/__init__.py @@ -53,7 +53,8 @@ def _add_windows_dll_directories(): from .gwr_basic import GWRBasic, ParallelType from .gwr_multiscale import GWRMultiscale -from .spatial_weight import SpatialWeight, BandwidthWeight, CRSDistance +from .gtwr import GTWR +from .spatial_weight import SpatialWeight, BandwidthWeight, CRSDistance, CRSSTDistance from .gwss import GWAverage, GWCorrelation if __name__ == "__main__": diff --git a/src/pygwmodel/gtwr.py b/src/pygwmodel/gtwr.py new file mode 100644 index 0000000..56e0c38 --- /dev/null +++ b/src/pygwmodel/gtwr.py @@ -0,0 +1,152 @@ +from typing import List, Optional +import numpy as np +import geopandas as gp +from .parallel import ParallelType +from ._regression import _GTWR + + +class GTWR: + """Geographically and Temporally Weighted Regression. + + GTWR extends GWR by incorporating temporal information via a + spatio-temporal distance metric with a spatio-temporal ratio + parameter (lambda). + + Parameters + ---------- + sdf : GeoDataFrame + Input data with geometry column. + depen_var : str + Dependent variable column name. + indep_vars : List[str] + Independent variable column names. + times : str + Column name for temporal stamps. + weight : BandwidthWeight + Bandwidth weight configuration. + distance : CRSSTDistance, optional + Spatio-temporal distance (default CRSSTDistance). + has_intercept : bool, optional + Include intercept term (default True). + """ + + BandwidthSelectionCriterionType = _GTWR.BandwidthSelectionCriterionType + + def __init__(self, sdf: gp.GeoDataFrame, depen_var: str, + indep_vars: List[str], times: str, + weight, distance=None, + has_intercept: bool = True): + if not isinstance(sdf, gp.GeoDataFrame): + raise ValueError("sdf must be a GeoDataFrame") + self.geometry = sdf.geometry + self.depen_var: str = depen_var + self.indep_vars: List[str] = indep_vars + self.times_col: str = times + self.has_intercept: bool = has_intercept + self.weight = weight + if distance is None: + from .spatial_weight import CRSSTDistance + distance = CRSSTDistance() + self.distance = distance + self.result_layer: Optional[gp.GeoDataFrame] = None + self.algorithm = _GTWR() + coords = np.asfortranarray( + sdf.geometry.centroid.get_coordinates(), dtype=np.float64) + self.algorithm.coords = coords + times_vec = np.asfortranarray(sdf[self.times_col], dtype=np.float64) + self.algorithm.set_times_vec(times_vec) + indep_vars_data = np.asfortranarray( + sdf[self.indep_vars], dtype=np.float64) + if self.has_intercept: + indep_vars_data = np.hstack( + [np.ones((indep_vars_data.shape[0], 1)), indep_vars_data]) + self.algorithm.independent = indep_vars_data + self.algorithm.dependent = np.asfortranarray( + sdf[self.depen_var], dtype=np.float64) + from .spatial_weight import SpatialWeight + self.algorithm.spatial_weight = \ + SpatialWeight.create(distance, weight) + self.algorithm.prepare_st_distance() + + def enable_parallel(self, type: ParallelType, **kwargs): + if type == ParallelType.OpenMP: + threads = kwargs.get('threads', 8) + if isinstance(threads, int) and threads > 0: + self.algorithm.parallel_omp(threads) + else: + raise ValueError("threads must be a positive integer") + return self + + def fit(self, + optimize_bandwidth: + Optional[BandwidthSelectionCriterionType] = None): + if (self.weight.bandwidth is None + and optimize_bandwidth is None): + optimize_bandwidth = GTWR.BandwidthSelectionCriterionType.CV + if optimize_bandwidth is not None: + v = optimize_bandwidth + self.algorithm.set_select_bandwidth(True, v.value) + self.algorithm.fit() + if optimize_bandwidth is not None: + self.weight.bandwidth = \ + self.algorithm.spatial_weight.weight()[1] + indep_var_names = (['Intercept'] if self.has_intercept else []) \ + + self.indep_vars + result_data = { + **{f: self.algorithm.betas[:, i] + for i, f in enumerate(indep_var_names)}, + **{f'{f}_SE': self.algorithm.betasSE[:, i] + for i, f in enumerate(indep_var_names)}, + 'fitted': np.sum( + self.algorithm.independent * self.algorithm.betas, axis=1) + } + self.result_layer = gp.GeoDataFrame( + result_data, geometry=self.geometry) + return self + + def predict(self, targets: gp.GeoDataFrame): + if self.weight.bandwidth is None: + raise ValueError("Bandwidth cannot be None when predicting") + predict_locations = np.asfortranarray( + targets.geometry.centroid.get_coordinates()) + coef_predict = self.algorithm.predict(predict_locations) + indep_var_names = (['Intercept'] if self.has_intercept else []) \ + + self.indep_vars + result_data = { + **{f: coef_predict[:, i] + for i, f in enumerate(indep_var_names)} + } + if all(x in targets.columns for x in self.indep_vars): + px = np.asfortranarray(targets[self.indep_vars]) + if self.has_intercept: + px = np.hstack([np.ones((px.shape[0], 1)), px]) + result_data['y_hat'] = np.sum(px * coef_predict, axis=1) + if self.depen_var in targets.columns: + py = targets[self.depen_var] + result_data["residual"] = py - result_data["y_hat"] + return gp.GeoDataFrame(result_data, geometry=targets.geometry) + + @property + def diagnostic(self): + return self.algorithm.diagnostic if self.algorithm else None + + @property + def betas(self): + return self.algorithm.betas if self.algorithm else None + + @property + def betasSE(self): + return self.algorithm.betasSE if self.algorithm else None + + @property + def bandwidth_criterions(self): + return self.algorithm.bandwidth_criterions \ + if self.algorithm else None + + @property + def lambda_(self): + return self.algorithm.lambda_ if self.algorithm else None + + @property + def angle(self): + return self.algorithm.angle if self.algorithm else None diff --git a/src/pygwmodel/spatial_weight.py b/src/pygwmodel/spatial_weight.py index 6cff712..c914bd4 100644 --- a/src/pygwmodel/spatial_weight.py +++ b/src/pygwmodel/spatial_weight.py @@ -12,14 +12,35 @@ def as_args(self) -> tuple: class CRSDistance(Distance): is_geographic = False - def __init__(self, is_geographic: bool=False) -> None: + def __init__(self, is_geographic: bool = False) -> None: super().__init__() self.is_geographic = is_geographic - + def as_args(self): return (self.is_geographic,) +class CRSSTDistance(Distance): + """Spatio-temporal distance for GTWR models. + + Parameters + ---------- + is_geographic : bool + Whether coordinates are geographic (lat/lon). + lambda_ : float + Spatio-temporal ratio parameter in [0, 1]. + """ + + def __init__(self, is_geographic: bool = False, + lambda_: float = 0.5) -> None: + super().__init__() + self.is_geographic = is_geographic + self.lambda_ = lambda_ + + def as_args(self): + return (self.is_geographic, self.lambda_) + + class Weight: def as_args(self) -> tuple: @@ -34,24 +55,28 @@ class BandwidthWeight(Weight): adaptive: bool = False kernel: Kernel = Kernel.Gaussian - def __init__(self, bandwidth: Optional[float]=None, adaptive: bool=False, kernel: Kernel=Kernel.Gaussian) -> None: + def __init__(self, bandwidth: Optional[float] = None, + adaptive: bool = False, + kernel: Kernel = Kernel.Gaussian) -> None: super().__init__() self.bandwidth = bandwidth self.adaptive = adaptive self.kernel = kernel - + def as_args(self) -> tuple: return (self.bandwidth, self.adaptive, self.kernel.value) class SpatialWeight: - + @staticmethod def create(distance: Distance, weight: Weight) -> None: sw = _SpatialWeight() ''' Set distance ''' - if isinstance(distance, CRSDistance): + if isinstance(distance, CRSSTDistance): + sw.set_distance_crst(*distance.as_args()) + elif isinstance(distance, CRSDistance): sw.set_distance_crs(*distance.as_args()) else: raise TypeError("Distance type not supported") diff --git a/src/regression.cpp b/src/regression.cpp index 31484d2..5d427e7 100644 --- a/src/regression.cpp +++ b/src/regression.cpp @@ -7,6 +7,7 @@ namespace nb = nanobind; void init_base(nb::module_& m); void init_gwr_basic(nb::module_& m); void init_gwr_multiscale(nb::module_& m); +void init_gtwr(nb::module_& m); NB_MODULE(_regression, m) { @@ -38,4 +39,5 @@ NB_MODULE(_regression, m) init_gwr_basic(m); init_gwr_multiscale(m); + init_gtwr(m); } diff --git a/src/spatial_weight.cpp b/src/spatial_weight.cpp index c5a5269..a67421f 100644 --- a/src/spatial_weight.cpp +++ b/src/spatial_weight.cpp @@ -1,6 +1,8 @@ #include #include #include +#include +#include #include "common.hpp" namespace nb = nanobind; @@ -57,5 +59,16 @@ NB_MODULE(_spatial_weight, m) sw.setDistance(gwm::CRSDistance(geographic)); } ) + .def( + "set_distance_crst", + [](gwm::SpatialWeight &sw, bool geographic, double lambda) + { + sw.setDistance(gwm::CRSSTDistance( + new gwm::CRSDistance(geographic), + new gwm::OneDimDistance(), + lambda + )); + } + ) ; }; diff --git a/test/test_gtwr.py b/test/test_gtwr.py new file mode 100644 index 0000000..5fed4a1 --- /dev/null +++ b/test/test_gtwr.py @@ -0,0 +1,102 @@ +import os +import sys +from pathlib import Path +import unittest +import numpy as np +import pandas as pd +import geopandas as gp +from pygwmodel import GTWR, ParallelType, BandwidthWeight +from pygwmodel.spatial_weight import CRSSTDistance + +TEST_DATA = os.environ.get("PYGW_TEST_DATA") +if TEST_DATA is None and len(sys.argv) > 1 and Path(sys.argv[1]).is_file(): + TEST_DATA = sys.argv[1] +if TEST_DATA is None: + TEST_DATA = str(Path(__file__).with_name("londonhp100.csv")) +ENABLE_OPENMP = (lambda s: False if s is None else (s.lower() in ['true', '1', 't', 'y', 'yes', 'on']))(os.getenv("ENABLE_OPENMP")) + + +class TestGTWR(unittest.TestCase): + + def setUp(self): + londonhp_csv = pd.read_csv(TEST_DATA) + self.londonhp = gp.GeoDataFrame( + londonhp_csv, + geometry=gp.points_from_xy(londonhp_csv.x, londonhp_csv.y)) + self.londonhp['TIME'] = np.arange(len(self.londonhp), + dtype=np.float64) + self.depen = 'PURCHASE' + self.indep = ["FLOORSZ", "UNEMPLOY", "PROF"] + self.distance = CRSSTDistance(lambda_=0.5) + self.parallel_case = { + ParallelType.SerialOnly: dict() + } + if ENABLE_OPENMP: + self.parallel_case[ParallelType.OpenMP] = {'threads': 4} + + def test_minimal(self): + for p, pargs in self.parallel_case.items(): + with self.subTest(parallel=p): + algorithm = GTWR( + self.londonhp, self.depen, self.indep, times='TIME', + weight=BandwidthWeight(36.0, True), + distance=self.distance + ).enable_parallel(p, **pargs).fit() + + self.assertIsNotNone(algorithm.result_layer) + indep_var_names = ['Intercept'] + self.indep + for name in indep_var_names: + self.assertIn(name, algorithm.result_layer.columns) + self.assertIn('Intercept_SE', algorithm.result_layer.columns) + self.assertIn('fitted', algorithm.result_layer.columns) + + diag = algorithm.diagnostic + self.assertIsNotNone(diag) + self.assertIn('AIC', diag) + self.assertIn('AICc', diag) + self.assertIn('RSquare', diag) + self.assertGreater(diag['RSquare'], 0) + self.assertLess(diag['RSquare'], 1) + + def test_autoselect_bandwidth(self): + for p, pargs in self.parallel_case.items(): + with self.subTest(parallel=p): + algorithm = GTWR( + self.londonhp, self.depen, self.indep, times='TIME', + weight=BandwidthWeight(36.0, True), + distance=self.distance + ).enable_parallel(p, **pargs).fit( + optimize_bandwidth=GTWR.BandwidthSelectionCriterionType.CV + ) + self.assertIsNotNone(algorithm.weight.bandwidth) + self.assertGreater(algorithm.weight.bandwidth, 0) + + def test_predict(self): + for p, pargs in self.parallel_case.items(): + with self.subTest(parallel=p): + algorithm = GTWR( + self.londonhp, self.depen, self.indep, times='TIME', + weight=BandwidthWeight(36.0, True), + distance=self.distance + ).enable_parallel(p, **pargs).fit() + prediction = algorithm.predict(self.londonhp) + self.assertIn("y_hat", prediction.columns) + self.assertIn("residual", prediction.columns) + + def test_fixed_bandwidth(self): + for p, pargs in self.parallel_case.items(): + with self.subTest(parallel=p): + algorithm = GTWR( + self.londonhp, self.depen, self.indep, times='TIME', + weight=BandwidthWeight(100.0, True), + distance=self.distance + ).enable_parallel(p, **pargs).fit() + + self.assertIsNotNone(algorithm.result_layer) + diag = algorithm.diagnostic + self.assertIsNotNone(diag) + self.assertGreater(diag['RSquare'], 0) + + +if __name__ == '__main__': + unittest.main(argv=[''], verbosity=2)