diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a88ddbe --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,58 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint: + name: Lint (ruff) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: python -m pip install ruff + - run: ruff check src tests + - run: ruff format --check src tests + + test: + name: Test (Python ${{ matrix.python-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + - name: Install + run: python -m pip install -e ".[dev,osm,contour]" + - name: Test + run: pytest --cov --cov-report=term-missing + + build: + name: Build distribution + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: python -m pip install build twine + - run: python -m build + - run: twine check dist/* + - uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..9660dd1 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,45 @@ +name: Docs + +on: + push: + branches: [master] + paths: ["docs/**", "mkdocs.yml", "src/**", "CHANGELOG.md", "CONTRIBUTING.md", ".github/workflows/docs.yml"] + pull_request: + paths: ["docs/**", "mkdocs.yml", "src/**", "CHANGELOG.md", "CONTRIBUTING.md", ".github/workflows/docs.yml"] + workflow_dispatch: + +permissions: + contents: write + +jobs: + build: + name: Build (strict) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + - run: python -m pip install -e ".[docs]" + - run: mkdocs build --strict + + deploy: + name: Deploy to GitHub Pages + needs: build + if: github.event_name != 'pull_request' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + - run: python -m pip install -e ".[docs]" + - name: Publish to the gh-pages branch + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + mkdocs gh-deploy --force --no-history diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..ff46db3 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,47 @@ +name: Publish to PyPI + +# Publishes when a version tag (v1.2.3) is pushed. Uses PyPI "trusted +# publishing" (OpenID Connect), so no API token is stored in the repository: +# register this workflow once at https://pypi.org/manage/account/publishing/ +# (owner: adaj, repository: predspot, workflow: publish.yml, environment: pypi). + +on: + push: + tags: ["v[0-9]+.[0-9]+.[0-9]+*"] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Check that the tag matches the package version + run: | + python -m pip install -e . + PKG_VERSION="v$(python -c 'import predspot; print(predspot.__version__)')" + echo "tag=${GITHUB_REF_NAME} package=${PKG_VERSION}" + test "${GITHUB_REF_NAME}" = "${PKG_VERSION}" + - run: python -m pip install build twine + - run: python -m build + - run: twine check dist/* + - uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ + + publish: + needs: build + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/predspot + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + - uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.gitignore b/.gitignore index 1c0482f..7591d61 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,9 @@ build/ dist/ *.egg-info/ +.coverage +htmlcov/ +.ruff_cache/ +site/ +examples/cache/ +.cache/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e400e6e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,65 @@ +# Changelog + +All notable changes to Predspot are documented here. The format follows +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses +[Semantic Versioning](https://semver.org/). + +## [Unreleased] + +## [0.2.0] - 2026-09 + +Revival release: the code base now targets Python 3.10+ with current +versions of pandas (>= 2.2), GeoPandas (>= 1.0), scikit-learn and statsmodels. + +### Added +- `crime_mapping.get_city_shape("Natal, RN, Brazil")` — thin wrapper around + `osmnx.geocode_to_gdf` returning the raw city shape. +- `examples/natal.ipynb`: an executed end-to-end walkthrough on Natal with + synthetic data, also rendered in the documentation. +- `PredictionPipeline.evaluate` accepts a list of scorings and returns a + DataFrame (one CV pass for all metrics). +- README section explaining the framework (thesis, Chapter 3, Figures 7-12); + the README is now the documentation home page. +- `crime_mapping.load_study_area("City, Country")` fetches a study area + boundary from OpenStreetMap via `osmnx` (`pip install predspot[osm]`). +- `synthetic.generate_crimes` generates synthetic events inside any study + area: Gaussian hotspots plus uniform background, with trend, annual cycle, + day-of-week and hour-of-day patterns; reproducible with `seed`. +- `QuadratCount` mapping (event counts per cell) as a first-class alternative + to `KDE`, usable with hexagonal (`create_gridhexagonal`) and square + (`create_gridsquares`) grids inside `PredictionPipeline`. +- `pipeline.build_default_pipeline` and reproducible + `pipeline.generate_testdata(..., seed=...)`. +- `PredictionPipeline.features`, `.next_time` and `random_state`. +- Test suite (pytest) and continuous integration for Python 3.10-3.13. +- Documentation rebuilt with MkDocs (Material + mkdocstrings), deployed + automatically to GitHub Pages; replaces the Sphinx site. +- `pyproject.toml` packaging (src layout) and automated PyPI publishing. + +### Changed +- `tfreq` is optional in the feature classes (inferred from the series). +- Debug `print`s replaced with the `logging` module (`predspot` logger); the + `debug=` arguments were removed. +- Wrapper estimators expose their inner estimator as `.estimator` + (previously `._estimator`). +- `Dataset` no longer modifies the input DataFrame and requires the study + area to have a CRS. +- Grid centroids are computed in a projected CRS; grids accept study areas in + any CRS. +- `geojsoncontour` is an optional dependency (`pip install predspot[contour]`). + +### Removed +- Sphinx documentation sources and the committed HTML build. +- `QuadratCount2`, `KGrid` and the hard dependencies on `descartes`, + `contextily` and `rtree`. + +### Fixed +- Compatibility with pandas 2/3 (`'ME'` offsets, `DataFrame.append`, + positional `Series` indexing), GeoPandas 1.x (`sjoin(predicate=)`, CRS + strings, `gpd.datasets`) and scikit-learn 1.x (`FeatureUnion` internals). +- `Seasonality`/`Trend` never called `STL(...).fit()`. +- `FeatureScaling` had no `fit`, so scalers inside a `Pipeline` were never fitted. + +## [0.1.3] - 2020 + +Original master's thesis release. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..06ae5fb --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,52 @@ +# Contributing + +Thanks for your interest in Predspot! Issues and pull requests are welcome. + +## Development setup + +```bash +git clone https://github.com/adaj/predspot.git +cd predspot +python -m venv .venv && source .venv/bin/activate +pip install -e ".[dev,osm,contour]" +``` + +## Checks + +```bash +ruff check src tests # lint +ruff format src tests # format +pytest # tests (~10 s) +PREDSPOT_NETWORK_TESTS=1 pytest tests/test_load_study_area.py # also query OpenStreetMap +``` + +CI runs the same checks on Python 3.10 to 3.13 for every pull request. + +## Documentation + +The site is built with [MkDocs](https://www.mkdocs.org/) and +[Material](https://squidfunk.github.io/mkdocs-material/); API pages come from the +docstrings via mkdocstrings. + +```bash +pip install -e ".[docs]" +mkdocs serve # live preview at http://127.0.0.1:8000 +mkdocs build --strict # what CI runs +``` + +Pushing to `master` deploys the site to GitHub Pages automatically. The home +page is generated from `README.md` (see `docs/hooks/readme.py`), and the +example notebook is rendered from `examples/natal.ipynb`; regenerate and +re-execute it with: + +```bash +python examples/build_natal_notebook.py +jupyter nbconvert --to notebook --execute --inplace examples/natal.ipynb +``` + +## Releasing + +1. Bump `__version__` in `src/predspot/__init__.py` and update `CHANGELOG.md`. +2. Merge to `master`, then tag and push: `git tag v0.2.0 && git push origin v0.2.0`. +3. The `Publish to PyPI` workflow builds the distribution and uploads it via + PyPI trusted publishing. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..9da02ae --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,3 @@ +include LICENSE README.md CHANGELOG.md CONTRIBUTING.md +recursive-include tests *.py +prune docs diff --git a/README.md b/README.md index 5ca6949..a341d08 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,44 @@ # Predspot +[![CI](https://github.com/adaj/predspot/actions/workflows/ci.yml/badge.svg)](https://github.com/adaj/predspot/actions/workflows/ci.yml) +[![Docs](https://github.com/adaj/predspot/actions/workflows/docs.yml/badge.svg)](https://adaj.github.io/predspot/) +[![PyPI](https://img.shields.io/pypi/v/predspot.svg)](https://pypi.org/project/predspot/) +[![Python](https://img.shields.io/pypi/pyversions/predspot.svg)](https://pypi.org/project/predspot/) +[![License: BSD-3](https://img.shields.io/badge/license-BSD--3--Clause-blue.svg)](https://github.com/adaj/predspot/blob/master/LICENSE) + ## Overview 📖 -Predspot is a Python library for spatio-temporal crime prediction and hotspot detection. It combines machine learning techniques with spatial analysis to help predict and visualize crime patterns across time and space. +Predspot is a Python library for spatio-temporal crime prediction and hotspot +detection. It turns a table of georeferenced, timestamped crime events into a +grid of *places* and a sequence of *periods*, builds time series features for +every place and trains a scikit-learn model to forecast where the next period's +hotspots will be. Key features: -- Spatial and temporal crime mapping -- Feature engineering for time series data -- Machine learning-based prediction pipeline -- Crime hotspot detection using Kernel Density Estimation -- Visualization tools for crime patterns -## Status 🚧 +- Spatio-temporal crime mapping: kernel density estimation (KDE) on point grids, + or event counts on hexagonal and square grids, at daily, weekly or monthly resolution +- Time series feature engineering: lagged autoregressive, difference, seasonal and + trend features (STL decomposition) +- A prediction pipeline that accepts any scikit-learn regressor, with time series + cross-validation and recursive multi-step forecasts +- Study areas fetched from OpenStreetMap by name +- A synthetic crime generator (hotspots, trend, annual, weekly and hourly patterns) + to try everything without real data -Predspot started as part of a master's thesis (2018-2019) and is being revived -and modernised. The code base now targets Python 3.10+ with current versions of -pandas (>= 2.2), GeoPandas (>= 1.0), scikit-learn and statsmodels, and is -covered by a test suite. It remains research software: use it as a reference -implementation and adapt it to your own data. +Full documentation, with a quickstart, a user guide and the API reference, lives at +**https://adaj.github.io/predspot/**. A complete, executed walkthrough for the city +of Natal is in [`examples/natal.ipynb`](https://github.com/adaj/predspot/blob/master/examples/natal.ipynb). ## How to use? 🚀 -> Documentation under construction. +Install from PyPI (Python 3.10 or newer): + +```bash +pip install predspot # core +pip install "predspot[osm]" # + study areas from OpenStreetMap (osmnx) +pip install "predspot[contour]" # + GeoJSON contour export (geojsoncontour) +``` Basic usage example: @@ -29,15 +46,14 @@ Basic usage example: from predspot import Dataset, PredictionPipeline from predspot.crime_mapping import KDE, create_gridpoints from predspot.feature_engineering import Seasonality, Trend, Diff - from predspot.utilities import PandasFeatureUnion from sklearn.ensemble import RandomForestRegressor -# Load and prepare data: crimes_df needs `tag`, `t`, `lon`, `lat` columns and +# crimes_df needs `tag`, `t`, `lon`, `lat` columns; # study_area_gdf is a GeoDataFrame with the boundary of the study area dataset = Dataset(crimes_df, study_area_gdf) -# Create prediction pipeline (monthly KDE on a 1 km point grid) +# Monthly KDE on a 1 km point grid, lag features, random forest pipeline = PredictionPipeline( mapping=KDE(tfreq='M', grid=create_gridpoints(study_area_gdf, resolution=1)), fextraction=PandasFeatureUnion([ @@ -62,6 +78,23 @@ from predspot.crime_mapping import QuadratCount, create_gridhexagonal mapping = QuadratCount(tfreq='W', grid=create_gridhexagonal(study_area_gdf, resolution=1)) ``` +### Study area from OpenStreetMap and synthetic data + +You do not need real data to try Predspot. Fetch a city boundary from +OpenStreetMap (`pip install "predspot[osm]"`) and generate synthetic events +with spatial hotspots and realistic temporal patterns (trend, annual cycle, +day-of-week and hour-of-day profiles): + +```python +from predspot import Dataset, get_city_shape, generate_crimes + +city = get_city_shape("Natal, RN, Brazil") +crimes = generate_crimes(city, n_events=5000, n_hotspots=4, + start="2019-01-01", end="2020-12-31", seed=0) +dataset = Dataset(crimes, city) +dataset.plot() +``` + Or run the default pipeline in one call: ```python @@ -72,72 +105,127 @@ predictions, pipeline = run_prediction_pipeline(crimes, study_area, grid_resolut print(pipeline.evaluate('r2', cv=3)) ``` - -## Development ⚡ - -Predspot has four main modules: - -`dataset_preparation`: Module for preparing and managing crime datasets and study areas. - -`crime_mapping`: Module for spatial and temporal crime mapping: point, hexagonal and square grids, KDE-based density surfaces and per-cell counts (`QuadratCount`). - -`feature_engineering`: Module for time series feature engineering, including seasonality, trend, and difference features. - -`ml_modelling`: Module that implements the prediction pipeline and model evaluation. - -### Installation steps 🛠️ - -Predspot requires Python 3.10 or newer. - -```bash -git clone https://github.com/adaj/predspot.git -cd predspot -pip install . -``` - -Core dependencies (installed automatically): pandas, geopandas, shapely, -numpy, scipy, scikit-learn, statsmodels and matplotlib. The optional -`geojsoncontour` package enables `predspot.utilities.contour_geojson`. - -### Tests 🧪 - -```bash -pip install pytest -pytest -``` - -### Input Data Format 📊 +### Input data format 📊 The crime data should be a pandas DataFrame with the following required columns: -- `tag`: Crime type -- `t`: Timestamp -- `lon`: Longitude -- `lat`: Latitude -The study area should be a GeoDataFrame defining the geographical boundaries of interest. +- `tag`: crime type +- `t`: timestamp +- `lon`: longitude (WGS84 degrees) +- `lat`: latitude (WGS84 degrees) + +The study area should be a GeoDataFrame (with a CRS) defining the boundaries +of interest. + +## The Predspot framework 🧭 + +Predspot implements the framework described in Chapter 3 of the master's +thesis [*Predspot: predicting crime hotspots with machine learning*](https://repositorio.ufrn.br/server/api/core/bitstreams/3655b8e1-2f32-4ce9-af9c-0e6b64d7af84/content) +(Araújo Jr., 2019). The framework is split into two phases, mirroring the +training and prediction steps of a machine learning system: **model +selection**, where a model is trained, evaluated and saved, and **prediction +service**, where that model is used operationally, period after period. The +figures below are reproduced from the thesis. + +### Model selection + +**1. Dataset preparation** *(Figure 7)* — everything starts from three inputs: +a crime database, the city shape and, optionally, auxiliary Points of Interest +(PoI) from OpenStreetMap. + +

Figure 7 - dataset preparation

+ +- Crime records must carry at least latitude, longitude, timestamp and crime type. +- The city shape acts as a spatial filter: events and PoI outside the boundary are dropped, + along with duplicates and "default" locations assigned to badly registered events. +- Crimes are split into **crime scenarios** (one per crime type, and possibly + per day/night period) that are modelled separately, since aggregating different + crime types blurs their distinct spatial patterns. +- In the package: `Dataset` (validation, WGS84 points) and `get_city_shape` / + `load_study_area` (city boundary from OpenStreetMap). + +**2. Feature ingest** *(Figures 8, 9 and 10)* — the step that assembles the +feature matrix `X` and the target `y`. Before it starts, two units of analysis +are chosen: the **spatial unit** (a grid derived from the crime mapping method) +and the **temporal unit** (daily, weekly or monthly samples — finer units give +sparser, harder-to-predict series). + +

Figure 8 - feature ingest

+ +- **Spatio-temporal aggregation**: the mapping method (KDE on a point grid, or + counts on polygonal cells) turns the events of each period into one value per + grid cell, producing the time series `C_ij` of cell *i* at period *j*. +- **Temporal feature extraction** *(Figure 9)*: each cell's series is decomposed + with STL into **trend** (`T`) and **seasonal** (`S`) components, and + **differenced** (`D`); the features are the *k* most recent lags of each + component, and the target is the series value one period ahead. + +

Figure 9 - temporal feature extraction

+ +- **Spatial aggregation of PoI** (optional): the density of each PoI category + around each cell (`G`) describes places geographically, complementing the + temporal features. +- **Join**: temporal and geographic features are joined into one row per + `(place, time)` pair — the layout shown in Figure 10. + +

Figure 10 - an artificial feature set

+ +- In the package: `create_gridpoints` / `create_gridhexagonal` / + `create_gridsquares` (grid), `KDE` / `QuadratCount` (spatio-temporal + aggregation), `Trend`, `Seasonality`, `Diff`, `AR` and `PandasFeatureUnion` + (temporal feature extraction and join). PoI features are not part of the + current package release. + +**3. Machine learning modelling** *(Figure 11)* — the feature set feeds a +model-agnostic training loop. + +

Figure 11 - machine learning modelling

+ +- **Feature selection** first: noisy lags and PoI layers are filtered by a + learning-based selector (an embedded, tree-based method in the thesis). +- **Several supervised algorithms** are trained and **tuned**, rather than + betting on a single one; the thesis compared random forests and gradient boosting. +- **Evaluation** uses time series K-fold cross-validation (train on the first + *k* folds, test on fold *k*+1), so no information from the future leaks into + training. The best model per crime scenario is the **golden model**, saved + together with its selected features. +- In the package: `PredictionPipeline` (with `FeatureScaling`, + `FeatureSelection` and `Model` wrappers and `evaluate()` for the time series CV). + +### Prediction service + +**4. Prediction pipeline** *(Figure 12)* — the model selection steps are +tailored for operation. + +

Figure 12 - prediction pipeline

+ +- For each new period, only the most recent events are loaded (enough to + compute the *k* lags), filtered and split into scenarios as before. +- The spatio-temporal aggregation and temporal feature extraction are repeated; + PoI features are reused, since they do not change over time. +- The previously selected features are kept and the golden model returns the + crime incidence level `y_i` of every place *i* one period ahead — a + prediction layer ready to be mapped. The process repeats every period. +- In the package: `PredictionPipeline.predict()` appends each forecast to the + series and advances one period, so repeated calls walk forward in time. + +**5. Web service** — the thesis also outlines how to wrap the pipeline in a +decoupled service (with a file *volume* for data, models and predictions, and +an ETL controller triggering the pipeline each period) that serves predictions +as GeoJSON to existing GIS tools. That layer is outside the scope of this +package, which covers the model selection phase and the prediction pipeline. ## Resources 📚 -For more information on the methods used in Predspot, please search more about these methods: -- Kernel Density Estimation for crime hotspot detection -- Time series decomposition for feature engineering -- Spatio-temporal crime prediction techniques - -## License 📜 - -BSD 3-Clause - -## Contributing 💡 - -Contributions are welcome! Please feel free to submit a Pull Request. - -Guidelines for contributing: -1. Fork the repository -2. Create your feature branch -3. Commit your changes -4. Push to the branch -5. Create a new Pull Request - +- Master's thesis (full description of the framework, evaluation on Natal and + Boston, feature importance analysis): Araújo Jr., A. (2019). + [*Predspot: Predicting Crime Hotspots with Machine Learning*](https://repositorio.ufrn.br/server/api/core/bitstreams/3655b8e1-2f32-4ce9-af9c-0e6b64d7af84/content). + M.Sc. dissertation, PPgSC/UFRN, Natal, Brazil. +- The earlier version of the framework: Araújo et al. (2018), *Towards a crime + hotspot detection framework for patrol planning* (HPCC/SmartCity/DSS). +- Methods worth reading about: kernel density estimation for hotspot mapping + (Chainey, Tompson & Uhlig, 2008), STL time series decomposition (Cleveland + et al., 1990) and time series cross-validation (Bergmeir, Hyndman & Koo, 2018). ## Cite us @@ -145,18 +233,19 @@ If you use Predspot in your research, please cite us: APA: ``` -Araujo, A., & Cacho, N. (2019). Predspot: Predicting crime hotspots with machine learning. Master’s thesis, UFRN (Universidade Federal do Rio Grande do Norte), Natal, Brazil. +Araújo Jr., A. (2019). Predspot: Predicting Crime Hotspots with Machine Learning. Master's dissertation, UFRN (Universidade Federal do Rio Grande do Norte), Natal, Brazil. Araújo, A., Cacho, N., Bezerra, L., Vieira, C., & Borges, J. (2018, June). Towards a crime hotspot detection framework for patrol planning. In 2018 IEEE 20th International Conference on High Performance Computing and Communications; IEEE 16th International Conference on Smart City; IEEE 4th International Conference on Data Science and Systems (HPCC/SmartCity/DSS) (pp. 1256-1263). IEEE. ``` or bibtex: ``` -@article{araujo2019predspot, +@mastersthesis{araujo2019predspot, title={Predspot: Predicting crime hotspots with machine learning}, author={Araujo, Adelson}, year={2019}, - school={Universidade Federal do Rio Grande do Norte} + school={Universidade Federal do Rio Grande do Norte}, + url={https://repositorio.ufrn.br/server/api/core/bitstreams/3655b8e1-2f32-4ce9-af9c-0e6b64d7af84/content} } @inproceedings{araujo2018towards, @@ -169,4 +258,53 @@ or bibtex: } ``` +## Development ⚡ + +Predspot has five main modules: + +- `dataset_preparation`: preparing and managing crime datasets and study areas. +- `crime_mapping`: spatial and temporal crime mapping — point, hexagonal and + square grids, KDE density surfaces, per-cell counts (`QuadratCount`) and + study areas from OpenStreetMap. +- `feature_engineering`: time series feature engineering (seasonality, trend, + difference and autoregressive lags). +- `ml_modelling`: the prediction pipeline and model evaluation. +- `synthetic`: synthetic crime events (hotspots + temporal patterns) inside any study area. + +From source, for development: + +```bash +git clone https://github.com/adaj/predspot.git +cd predspot +pip install -e ".[dev,osm,contour]" +ruff check src tests # lint +pytest # ~10 s +``` + +See [CONTRIBUTING.md](https://github.com/adaj/predspot/blob/master/CONTRIBUTING.md) +for the release process and +[CHANGELOG.md](https://github.com/adaj/predspot/blob/master/CHANGELOG.md) for +what changed between versions. + +## Contributing 💡 + +Contributions are welcome! Please feel free to submit a Pull Request. + +Guidelines for contributing: +1. Fork the repository +2. Create your feature branch +3. Commit your changes +4. Push to the branch +5. Create a new Pull Request + +## License 📜 + +BSD 3-Clause + +## Status 🚧 +Predspot started as part of a master's thesis (2018-2019) and was revived in +2026. The code base now targets Python 3.10+ with current versions of pandas +(>= 2.2), GeoPandas (>= 1.0), scikit-learn and statsmodels, and is covered by a +test suite and continuous integration. It remains research software: use it as +a reference implementation and adapt it to your own data. diff --git a/docs/Makefile b/docs/Makefile deleted file mode 100644 index aea4d07..0000000 --- a/docs/Makefile +++ /dev/null @@ -1,28 +0,0 @@ -# Minimal makefile for Sphinx documentation -# - -# You can set these variables from the command line, and also -# from the environment for the first two. -SPHINXOPTS ?= -SPHINXBUILD ?= sphinx-build -SOURCEDIR = source -BUILDDIR = build - -# Put it first so that "make" without argument is like "make help". -help: - @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) - -.PHONY: help Makefile - -# Catch-all target: route all unknown targets to Sphinx using the new -# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). -%: Makefile - @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) - -# Clean build directory -clean: - rm -rf $(BUILDDIR)/* - -# Build HTML documentation -html: - $(SPHINXBUILD) -b html $(SOURCEDIR) $(BUILDDIR)/html diff --git a/docs/api/crime_mapping.md b/docs/api/crime_mapping.md new file mode 100644 index 0000000..92d82e0 --- /dev/null +++ b/docs/api/crime_mapping.md @@ -0,0 +1 @@ +::: predspot.crime_mapping diff --git a/docs/api/dataset_preparation.md b/docs/api/dataset_preparation.md new file mode 100644 index 0000000..64f8c99 --- /dev/null +++ b/docs/api/dataset_preparation.md @@ -0,0 +1 @@ +::: predspot.dataset_preparation diff --git a/docs/api/feature_engineering.md b/docs/api/feature_engineering.md new file mode 100644 index 0000000..4d6c34b --- /dev/null +++ b/docs/api/feature_engineering.md @@ -0,0 +1 @@ +::: predspot.feature_engineering diff --git a/docs/api/ml_modelling.md b/docs/api/ml_modelling.md new file mode 100644 index 0000000..1dfde0c --- /dev/null +++ b/docs/api/ml_modelling.md @@ -0,0 +1 @@ +::: predspot.ml_modelling diff --git a/docs/api/pipeline.md b/docs/api/pipeline.md new file mode 100644 index 0000000..5635563 --- /dev/null +++ b/docs/api/pipeline.md @@ -0,0 +1 @@ +::: predspot.pipeline diff --git a/docs/api/predspot.md b/docs/api/predspot.md new file mode 100644 index 0000000..6cb916f --- /dev/null +++ b/docs/api/predspot.md @@ -0,0 +1,16 @@ +# predspot + +The top-level package re-exports the most used objects: + +```python +from predspot import ( + Dataset, PredictionPipeline, PandasFeatureUnion, + KDE, QuadratCount, create_gridpoints, create_gridhexagonal, create_gridsquares, + load_study_area, generate_crimes, +) +``` + +::: predspot + options: + members: false + show_source: false diff --git a/docs/api/synthetic.md b/docs/api/synthetic.md new file mode 100644 index 0000000..4098016 --- /dev/null +++ b/docs/api/synthetic.md @@ -0,0 +1 @@ +::: predspot.synthetic diff --git a/docs/api/utilities.md b/docs/api/utilities.md new file mode 100644 index 0000000..5188b93 --- /dev/null +++ b/docs/api/utilities.md @@ -0,0 +1 @@ +::: predspot.utilities diff --git a/docs/assets/forecast.png b/docs/assets/forecast.png new file mode 100644 index 0000000..bf58a8c Binary files /dev/null and b/docs/assets/forecast.png differ diff --git a/docs/assets/kde_vs_quadrat.png b/docs/assets/kde_vs_quadrat.png new file mode 100644 index 0000000..7796f90 Binary files /dev/null and b/docs/assets/kde_vs_quadrat.png differ diff --git a/docs/assets/synthetic_dataset.png b/docs/assets/synthetic_dataset.png new file mode 100644 index 0000000..ab10e49 Binary files /dev/null and b/docs/assets/synthetic_dataset.png differ diff --git a/docs/assets/thesis/fig07-dataset-preparation.png b/docs/assets/thesis/fig07-dataset-preparation.png new file mode 100644 index 0000000..8e9b663 Binary files /dev/null and b/docs/assets/thesis/fig07-dataset-preparation.png differ diff --git a/docs/assets/thesis/fig08-feature-ingest.png b/docs/assets/thesis/fig08-feature-ingest.png new file mode 100644 index 0000000..f5e4e4f Binary files /dev/null and b/docs/assets/thesis/fig08-feature-ingest.png differ diff --git a/docs/assets/thesis/fig09-temporal-feature-extraction.png b/docs/assets/thesis/fig09-temporal-feature-extraction.png new file mode 100644 index 0000000..0c12518 Binary files /dev/null and b/docs/assets/thesis/fig09-temporal-feature-extraction.png differ diff --git a/docs/assets/thesis/fig10-feature-set-example.png b/docs/assets/thesis/fig10-feature-set-example.png new file mode 100644 index 0000000..0cb40d4 Binary files /dev/null and b/docs/assets/thesis/fig10-feature-set-example.png differ diff --git a/docs/assets/thesis/fig11-ml-modelling.png b/docs/assets/thesis/fig11-ml-modelling.png new file mode 100644 index 0000000..f2c1f9c Binary files /dev/null and b/docs/assets/thesis/fig11-ml-modelling.png differ diff --git a/docs/assets/thesis/fig12-prediction-pipeline.png b/docs/assets/thesis/fig12-prediction-pipeline.png new file mode 100644 index 0000000..d26ec40 Binary files /dev/null and b/docs/assets/thesis/fig12-prediction-pipeline.png differ diff --git a/docs/build/doctrees/environment.pickle b/docs/build/doctrees/environment.pickle deleted file mode 100644 index 3546771..0000000 Binary files a/docs/build/doctrees/environment.pickle and /dev/null differ diff --git a/docs/build/doctrees/index.doctree b/docs/build/doctrees/index.doctree deleted file mode 100644 index 1332c52..0000000 Binary files a/docs/build/doctrees/index.doctree and /dev/null differ diff --git a/docs/build/doctrees/modules/crime_mapping.doctree b/docs/build/doctrees/modules/crime_mapping.doctree deleted file mode 100644 index fbee686..0000000 Binary files a/docs/build/doctrees/modules/crime_mapping.doctree and /dev/null differ diff --git a/docs/build/doctrees/modules/dataset_preparation.doctree b/docs/build/doctrees/modules/dataset_preparation.doctree deleted file mode 100644 index 3ef30d7..0000000 Binary files a/docs/build/doctrees/modules/dataset_preparation.doctree and /dev/null differ diff --git a/docs/build/doctrees/modules/feature_engineering.doctree b/docs/build/doctrees/modules/feature_engineering.doctree deleted file mode 100644 index 678553f..0000000 Binary files a/docs/build/doctrees/modules/feature_engineering.doctree and /dev/null differ diff --git a/docs/build/doctrees/modules/ml_modelling.doctree b/docs/build/doctrees/modules/ml_modelling.doctree deleted file mode 100644 index ee87006..0000000 Binary files a/docs/build/doctrees/modules/ml_modelling.doctree and /dev/null differ diff --git a/docs/build/doctrees/modules/pipeline.doctree b/docs/build/doctrees/modules/pipeline.doctree deleted file mode 100644 index 323aa1b..0000000 Binary files a/docs/build/doctrees/modules/pipeline.doctree and /dev/null differ diff --git a/docs/build/doctrees/modules/utilities.doctree b/docs/build/doctrees/modules/utilities.doctree deleted file mode 100644 index ac764ff..0000000 Binary files a/docs/build/doctrees/modules/utilities.doctree and /dev/null differ diff --git a/docs/build/html/.buildinfo b/docs/build/html/.buildinfo deleted file mode 100644 index 5d1f5a3..0000000 --- a/docs/build/html/.buildinfo +++ /dev/null @@ -1,4 +0,0 @@ -# Sphinx build info version 1 -# This file records the configuration used when building these files. When it is not found, a full rebuild will be done. -config: eee754e6bcf202973259ca7c261d9fbe -tags: 645f666f9bcd5a90fca523b33c5a78b7 diff --git a/docs/build/html/.nojekyll b/docs/build/html/.nojekyll deleted file mode 100644 index e69de29..0000000 diff --git a/docs/build/html/_modules/index.html b/docs/build/html/_modules/index.html deleted file mode 100644 index da71c50..0000000 --- a/docs/build/html/_modules/index.html +++ /dev/null @@ -1,141 +0,0 @@ - - - - - - - Overview: module code — Predspot 1.0 documentation - - - - - - - - - - - - - - - - - -
- - -
-
- - - - - - -
-
-
-
- - - - - - - \ No newline at end of file diff --git a/docs/build/html/_modules/predspot/crime_mapping.html b/docs/build/html/_modules/predspot/crime_mapping.html deleted file mode 100644 index ff724a2..0000000 --- a/docs/build/html/_modules/predspot/crime_mapping.html +++ /dev/null @@ -1,566 +0,0 @@ - - - - - - - predspot.crime_mapping — Predspot 1.0 documentation - - - - - - - - - - - - - - - - - -
- - -
-
- - - -
- -

Source code for predspot.crime_mapping

-"""
-Crime Mapping Module
-
-This module provides functionality for spatial and temporal crime mapping analysis.
-It includes utilities for creating grid points, hexagonal grids, and implementing
-kernel density estimation for crime hotspot detection.
-"""
-
-__author__ = 'Adelson Araujo'
-from abc import ABC, abstractmethod
-import math
-import numpy as np
-import pandas as pd
-import geopandas as gpd
-from sklearn.base import BaseEstimator, TransformerMixin
-from sklearn.cluster import KMeans
-from shapely.geometry import Point, Polygon, MultiPoint
-from scipy.stats import gaussian_kde
-
-pd.options.mode.chained_assignment = None
-
-
-
-[docs] -def create_gridpoints(bbox, resolution, return_coords=False, debug=False): - """ - Create a grid of points within a given bounding box. - - Args: - bbox (GeoDataFrame): Bounding box as a GeoDataFrame - resolution (float): Grid cell size in kilometers - return_coords (bool): If True, returns additional coordinate arrays - debug (bool): Enable debug printing - - Returns: - GeoDataFrame or tuple: Grid points as GeoDataFrame, optionally with coordinate arrays - """ - if debug: - print(f"Creating grid with resolution: {resolution}km", flush=True) - - assert resolution > 0, \ - "Invalid resolution." - assert isinstance(bbox, gpd.GeoDataFrame), \ - 'bbox must be geopandas GeoDataFrame.' - bounds = bbox.bounds - b_s, b_w = bounds.min().values[1], bounds.min().values[0] - b_n, b_e = bounds.max().values[3], bounds.max().values[2] - nlon = int(np.ceil((b_e-b_w) / (resolution/111.32))) - nlat = int(np.ceil((b_n-b_s) / (resolution/110.57))) - lonv, latv = np.meshgrid(np.linspace(b_w, b_e, nlon), np.linspace(b_s, b_n, nlat)) - gridpoints = pd.DataFrame(np.vstack([lonv.ravel(), latv.ravel()]).T, - columns=['lon', 'lat']) - gridpoints['geometry'] = gridpoints.apply(lambda x: Point([x['lon'], x['lat']]), - axis=1) - gridpoints = gpd.GeoDataFrame(gridpoints) - gridpoints.crs = {'init': 'epsg:4326'} - gridpoints = gridpoints.to_crs(bbox.crs) - grid_ix = gpd.sjoin(gridpoints, bbox, op='intersects').index.unique() - if len(grid_ix) == 0: - raise Exception("resolution too big/coarse. No cells were generated.") - # elif len(grid_ix) / bbox.area.sum() > 10: - # warnings.warn('resolution too fine/small. As consequence, your program' \ - # + 'may run very slowly.') - gridpoints = gridpoints.loc[grid_ix] - gridpoints.index.name = 'places' - if return_coords: - return gridpoints, lonv, latv - return gridpoints
- - - -
-[docs] -def create_hexagon(l, x, y): - """ - Create a hexagonal polygon. - - Args: - l (float): Length of hexagon side - x (float): X-coordinate of center - y (float): Y-coordinate of center - - Returns: - Polygon: Hexagonal polygon - """ - c = [[x + math.cos(math.radians(angle)) * l, y + math.sin(math.radians(angle)) * l] for angle in range(0, 360, 60)] - return Polygon(c)
- - - -
-[docs] -def create_gridhexagonal(bbox, resolution): - assert resolution > 0, \ - "Invalid resolution." - resolution = ((resolution)**2 * (2/(3*(3**0.5)))) ** 0.5 # normalize resolution to have the same area as if it was a square - assert isinstance(bbox, gpd.GeoDataFrame), \ - 'bbox must be geopandas GeoDataFrame.' - bbox_ = list(bbox.bounds.min().values[:2]) + list(bbox.bounds.max().values[-2:]) - x_min = min(bbox_[0], bbox_[2]) - x_max = max(bbox_[0], bbox_[2]) - y_min = min(bbox_[1], bbox_[3]) - y_max = max(bbox_[1], bbox_[3]) - grid = [] - resolution = resolution/110.6 - v_step = math.sqrt(3) * resolution - h_step = 1.5 * resolution - h_skip = math.ceil(x_min / h_step) - 1 - h_start = h_skip * h_step - v_skip = math.ceil(y_min / v_step) - 1 - v_start = v_skip * v_step - h_end = x_max + h_step - v_end = y_max + v_step - if v_start - (v_step / 2.0) < y_min: - v_start_array = [v_start + (v_step / 2.0), v_start] - else: - v_start_array = [v_start - (v_step / 2.0), v_start] - v_start_idx = int(abs(h_skip) % 2) - c_x = h_start - c_y = v_start_array[v_start_idx] - v_start_idx = (v_start_idx + 1) % 2 - while c_x < h_end: - while c_y < v_end: - grid.append(create_hexagon(resolution, c_x, c_y)) - c_y += v_step - c_x += h_step - c_y = v_start_array[v_start_idx] - v_start_idx = (v_start_idx + 1) % 2 - grid = gpd.GeoDataFrame(geometry=grid).reset_index() - grid.crs = {'init': 'epsg:4326'} - grid = grid.rename(columns={'index':'places'}).set_index('places') - if isinstance(bbox, gpd.GeoDataFrame): - grid = gpd.sjoin(grid, bbox, op='intersects')[grid.columns].drop_duplicates() - grid = grid.to_crs(bbox.crs) - grid['lon'] = grid['geometry'].centroid.x - grid['lat'] = grid['geometry'].centroid.y - return grid
- - - -
-[docs] -def create_gridsquares(city_shape, resolution=1): - """It constructs a grid of square cells. - - Parameters - ---------- - city_shape : GeoDataFrame. - Corresponds to the boundary geometry in which the grid will be formed. - - resolution : float, default is 1. - Space between the square cells. - """ - x0 = city_shape.bounds.min().values[0] - xf = city_shape.bounds.max().values[2] - y0 = city_shape.bounds.min().values[1] - yf = city_shape.bounds.max().values[3] - n_y = int((yf-y0)/(resolution/110.57)) - n_x = int((xf-x0)/(resolution/111.32)) - grid = {} - c = 0 - for i in range(n_x): - for j in range(n_y): - grid[c] = {'geometry':Polygon([[x0,y0], - [x0+(resolution/111.32),y0], - [x0+(resolution/111.32),y0+(resolution/110.57)], - [x0,y0+(resolution/110.57)]])} - c += 1 - y0 += resolution/110.57 - y0 = city_shape.bounds.min().values[1] - x0 += resolution/111.32 - grid = pd.DataFrame(grid).transpose() - grid = gpd.GeoDataFrame(grid) - grid.crs = {'init': 'epsg:4326'} - grid = grid.to_crs(city_shape.crs) - grid = gpd.sjoin(grid, city_shape, op='intersects')[grid.columns] - grid['lat'] = grid.centroid.y - grid['lon'] = grid.centroid.x - grid.index.name = 'places' - return grid[~grid.index.duplicated()]
- - - -
-[docs] -class QuadratCount(BaseEstimator, TransformerMixin): - - def __init__(self, tfreq, grid, filter_place_ratio=0.9): - self._tfreq = tfreq - self._grid = grid - self._filter_place_ratio = filter_place_ratio # pct of timestamps with at least one crime - -
-[docs] - def fit(self, x=None, y=None): - return self
- - -
-[docs] - def transform(self, data_points): - stseries = gpd.sjoin(data_points, self._grid).set_index('t')\ - .groupby([pd.Grouper(freq=self._tfreq), 'index_right'])\ - .size().unstack(fill_value=0).stack() - stseries.index.names = ['t', 'places'] - c_places = stseries.groupby(['places']).agg(lambda x: x.eq(0).sum()) - n_timestamps = len(stseries.index.get_level_values('t').unique()) - c_places = c_places.loc[c_places < self._filter_place_ratio * n_timestamps].index - stseries = stseries.loc[pd.IndexSlice[:, c_places]] - self._grid = self._grid.loc[c_places] - return stseries
-
- - - -
-[docs] -class QuadratCount2(BaseEstimator, TransformerMixin): - - def __init__(self, tfreq, grid, filter_place_ratio=0.9): - self._tfreq = tfreq - self._grid = grid - self._filter_place_ratio = filter_place_ratio # pct of timestamps with at least one crime - - -
-[docs] - def transform(self, data_points): - stseries = gpd.sjoin(data_points, self._grid).set_index('t')\ - .groupby([pd.Grouper(freq=self._tfreq), 'index_right'])\ - .size().unstack(fill_value=0).stack() - stseries.index.names = ['t', 'places'] - c_places = stseries.groupby(['places']).agg(lambda x: x.eq(0).sum()) - n_timestamps = len(stseries.index.get_level_values('t').unique()) - c_places = c_places.loc[c_places < self._filter_place_ratio * n_timestamps].index - stseries = stseries.loc[pd.IndexSlice[:, c_places]] - self._grid = self._grid.loc[c_places] - return stseries
-
- - - -
-[docs] -class KGrid: - - def __init__(self, k, tfreq): - self._K = k - self._tfreq = tfreq - -
-[docs] - def fit(self, data_points): - self.km = KMeans(self._K).fit(data_points[['lat','lon']]) - crime_data = data_points.copy(deep=True) - crime_data['K'] = self.km.labels_ - self._grid = gpd.GeoDataFrame( - geometry=crime_data.groupby('K')\ - .apply(lambda x: MultiPoint(list(x['geometry'])).convex_hull)) - self._grid.crs = {'init': 'epsg:4326'} - self._grid = self._grid.to_crs(crime_data.crs) - crimes_per_cell = gpd.sjoin(crime_data, self._grid)\ - .groupby('index_right').size() - self._grid = self._grid.loc[crimes_per_cell > crimes_per_cell.mean()] - return self
- - -
-[docs] - def transform(self, data_points): - stseries = gpd.sjoin(data_points, self._grid).set_index('t')\ - .groupby([pd.Grouper(freq=self._tfreq), 'index_right'])\ - .size().unstack(fill_value=0).stack() - stseries.index.names = ['t', 'places'] - c_places = stseries.groupby(['places']).agg(lambda x: x.eq(0).sum()) - n_timestamps = len(stseries.index.get_level_values('t').unique()) - c_places = c_places.loc[c_places < 0.9 * n_timestamps].index - stseries = stseries.loc[pd.IndexSlice[:, c_places]] - self._grid = self._grid.loc[c_places] - return stseries
-
- - - -
-[docs] -class SpatioTemporalMapping(ABC, TransformerMixin, BaseEstimator): # y - """ - Abstract base class for spatio-temporal crime mapping. - - Args: - tfreq (str): Time frequency ('M' for monthly, 'W' for weekly, 'D' for daily) - grid (GeoDataFrame): Spatial grid for analysis - start_time (str or datetime, optional): Analysis start time - end_time (str or datetime, optional): Analysis end time - """ - - def __init__(self, tfreq, grid, start_time=False, end_time=False, debug=False): - self.debug = debug - if self.debug: - print(f"Initializing SpatioTemporalMapping with frequency: {tfreq}", flush=True) - assert tfreq.upper() in ['M', 'W', 'D'], \ - "Invalid tfreq. Please choose (m)onthly, (w)eekly or (d)aily." - assert all([x in grid.columns for x in ['geometry', 'lon', 'lat']]), \ - "Input grid must have `geometry`, `lon` and `lat` columns." - self._tfreq = tfreq.upper() - self._grid = grid - self._start_time = pd.to_datetime(start_time) if start_time else False - self._end_time = pd.to_datetime(end_time) if end_time else False - -
-[docs] - @abstractmethod - def fit_grid(self, data_points=None): - pass
- - -
-[docs] - def get_time_data_chunks(self, data_points): - chunks = data_points.set_index('t').resample(self._tfreq) - chunks = pd.DataFrame(chunks, - columns=['t', 'crime_chunks']) - chunks = chunks.set_index('t').sort_values('t') - return chunks.apply(lambda x: x[0], axis=1)
- - -
-[docs] - def get_times_no_data(self, chunks): - if not self._start_time: - self._start_time = chunks.index.min() - if not self._end_time: - self._end_time = chunks.index.max() - times_between = pd.date_range(self._start_time, self._end_time, freq=self._tfreq) - no_data = {cell:0 for cell in self._grid.index} - times_no_data = set(times_between) - set(chunks.index) - if len(times_no_data) == 0: - return chunks - times_no_data = pd.DataFrame(index=times_no_data) - times_no_data['crime_density'] = [no_data] * len(times_no_data) - return times_no_data
- - -
-[docs] - def fit(self, x, y=None): - return self
- - -
-[docs] - def transform(self, data_points): - chunks = self.get_time_data_chunks(data_points) - stseries = chunks.apply(self.fit_grid).to_frame('crime_density') - times_no_data = self.get_times_no_data(chunks) - if len(times_no_data) != len(chunks): - stseries = stseries.append(times_no_data) - time_ix = stseries.index - stseries = pd.json_normalize(data=stseries['crime_density']) - stseries.index = time_ix - stseries = stseries.unstack() - stseries.index.names = ['places','t'] - stseries = stseries.swaplevel().sort_index() - return stseries
-
- - - -
-[docs] -class KDE(SpatioTemporalMapping): # y - """ - Kernel Density Estimation for crime hotspot detection. - - Args: - tfreq (str): Time frequency ('M' for monthly, 'W' for weekly, 'D' for daily) - grid (GeoDataFrame): Spatial grid for analysis - start_time (str or datetime, optional): Analysis start time - end_time (str or datetime, optional): Analysis end time - bandwidth (str or float): Bandwidth method ('silverman' or numeric value) - """ - - def __init__(self, tfreq, grid, start_time=False, end_time=False, bandwidth='silverman', debug=False): - super().__init__(tfreq, grid, start_time, end_time) - self.debug = debug - self._bandwidth = bandwidth - self._kernel = None - - if self.debug: - print(f"Initializing KDE with bandwidth: {bandwidth}", flush=True) - -
-[docs] - def fit_grid(self, data_points, as_df=False): - """ - Fit the kernel density estimation to grid points. - - Args: - data_points (GeoDataFrame): Crime incident points - as_df (bool): If True, return results as DataFrame - - Returns: - dict or DataFrame: Density estimates for grid points - """ - if self.debug: - print(f"Fitting KDE grid with {len(data_points)} points", flush=True) - - if len(data_points) < 3: - crime_density = pd.DataFrame([0]*len(self._grid.index), - index=self._grid.index, - columns=['crime_density']) - else: - if isinstance(self._bandwidth, str): - self._kernel = gaussian_kde(np.vstack([data_points.centroid.x, - data_points.centroid.y]), - bw_method='silverman') - self._bandwidth = self._kernel.factor - else: - self._kernel = gaussian_kde(np.vstack([data_points.centroid.x, - data_points.centroid.y]), - bw_method=self._bandwidth) - crime_density = pd.DataFrame(self._kernel(self._grid[['lon', 'lat']].values.T), - index=self._grid.index, columns=['crime_density']) - if as_df: - return crime_density - return crime_density.to_dict()['crime_density']
-
- -
- -
- - -
-
-
-
- - - - - - - \ No newline at end of file diff --git a/docs/build/html/_modules/predspot/dataset_preparation.html b/docs/build/html/_modules/predspot/dataset_preparation.html deleted file mode 100644 index 1df371f..0000000 --- a/docs/build/html/_modules/predspot/dataset_preparation.html +++ /dev/null @@ -1,305 +0,0 @@ - - - - - - - predspot.dataset_preparation — Predspot 1.0 documentation - - - - - - - - - - - - - - - - - -
- - -
-
- - - -
- -

Source code for predspot.dataset_preparation

-"""
-Dataset Preparation Module
-
-This module provides functionality for preparing and managing crime datasets along with their
-corresponding study areas. It handles spatial data processing and visualization of crime incidents.
-"""
-
-__author__ = 'Adelson Araujo'
-
-import pandas as pd
-import geopandas as gpd
-import numpy as np
-import warnings
-from shapely.geometry import Point, LineString
-
-
-
-[docs] -class Dataset: - """ - A class to handle crime datasets and their associated study areas. - - Args: - crimes (pandas.DataFrame): DataFrame containing crime data with required columns: - 'tag', 't' (timestamp), 'lon' (longitude), and 'lat' (latitude) - study_area (geopandas.GeoDataFrame): GeoDataFrame defining the study area boundaries - debug (bool, optional): Enable debug printing. Defaults to False. - - Attributes: - crimes (geopandas.GeoDataFrame): Processed crime data with geometry - study_area (geopandas.GeoDataFrame): Study area boundaries - """ - - def __init__(self, crimes, study_area, debug=False):#, poi_data=None): - self.debug = debug - - if self.debug: - print("Initializing Dataset class", flush=True) - - assert isinstance(study_area, gpd.GeoDataFrame), \ - "study_area must be a geopandas GeoDataFrame." - self._study_area = study_area - - assert isinstance(crimes, pd.DataFrame) \ - and all([x in crimes.columns for x in ['tag', 't', 'lon', 'lat']]),\ - "Input crime data must be a pandas Data Frame and " \ - + "have at least `tag`, `t`, `lon` and `lat` as columns." - - if self.debug: - print(f"Processing {len(crimes)} crime incidents", flush=True) - - self._crimes = crimes - self._crimes['geometry'] = self._crimes.apply(lambda x: Point([x['lon'], x['lat']]), - axis=1) - self._crimes = gpd.GeoDataFrame(self._crimes, crs={'init': 'epsg:4326'}) - self._crimes['t'] = self._crimes['t'].apply(pd.to_datetime) - - if self.debug: - print("Dataset initialization complete", flush=True) - - def __repr__(self): - """ - String representation of the Dataset object. - - Returns: - str: A formatted string showing dataset statistics - """ - return 'predspot.Dataset<\n'\ - + f' crimes = GeoDataFrame({self._crimes.shape[0]}),\n' \ - + f' >> {self.crimes["tag"].value_counts().to_dict()}\n' \ - + f' study_area = GeoDataFrame({self._study_area.shape[0]}),\n' \ - + '>' - - @property - def crimes(self): - """ - Get the crime incidents data. - - Returns: - geopandas.GeoDataFrame: The processed crime incidents data - """ - return self._crimes - - @property - def study_area(self): - """ - Get the study area boundaries. - - Returns: - geopandas.GeoDataFrame: The study area boundaries - """ - return self._study_area - - @property - def shape(self): - """ - Get the shapes of the dataset components. - - Returns: - dict: Dictionary containing the shapes of crimes and study_area DataFrames - """ - return {'crimes': self._crimes.shape, - 'study_area': self._study_area.shape} - -
-[docs] - def plot(self, ax=None, crime_samples=1000, **kwargs): - """ - Plot the study area and crime incidents. - - Args: - ax (matplotlib.axes.Axes, optional): Matplotlib axes for plotting - crime_samples (int, optional): Number of crime samples to plot. Defaults to 1000 - **kwargs: Additional keyword arguments for plotting - study_area: kwargs for study area plot - crimes: kwargs for crime incidents plot - - Returns: - matplotlib.axes.Axes: The plot axes - """ - if self.debug: - print(f"Plotting dataset with {crime_samples} sample points", flush=True) - - if ax is None: - ax = self.study_area.plot(color='white', edgecolor='black', - **kwargs.pop('study_area',{})) - else: - self.study_area.plot(color='white', edgecolor='black', ax=ax, - **kwargs.pop('study_area',{})) - if crime_samples > len(self.crimes): - crime_samples = len(self.crimes) - self.crimes.sample(crime_samples).plot(ax=ax, marker='x', - **kwargs.pop('crimes',{})) - return ax
- - -
-[docs] - def train_test_split(self, test_size=0.25): - """ - Split the dataset into training and testing sets. - - Args: - test_size (float): Proportion of the dataset to include in the test split. - Must be between 0 and 1. Defaults to 0.25. - - Returns: - tuple: (train_dataset, test_dataset) - Two Dataset objects containing the splits - - Raises: - AssertionError: If test_size is not between 0 and 1 - """ - if self.debug: - print(f"Splitting dataset with test_size={test_size}", flush=True) - - assert 0 < test_size < 1, \ - 'test_size must be between 0 and 1.' - test_dataset = Dataset(self.crimes.sample(frac=test_size), self.study_area) - train_ix = set(self.crimes.index) - set(test_dataset.crimes.index) - train_dataset = Dataset(self.crimes.loc[train_ix], self.study_area) - - if self.debug: - print(f"Split complete - Train size: {len(train_dataset.crimes)}, " - f"Test size: {len(test_dataset.crimes)}", flush=True) - - return train_dataset, test_dataset
-
- -
- -
- - -
-
-
-
- - - - - - - \ No newline at end of file diff --git a/docs/build/html/_modules/predspot/feature_engineering.html b/docs/build/html/_modules/predspot/feature_engineering.html deleted file mode 100644 index 900a03a..0000000 --- a/docs/build/html/_modules/predspot/feature_engineering.html +++ /dev/null @@ -1,432 +0,0 @@ - - - - - - - predspot.feature_engineering — Predspot 1.0 documentation - - - - - - - - - - - - - - - - - -
- - -
-
- - - -
- -

Source code for predspot.feature_engineering

-"""
-Feature Engineering Module
-
-This module provides classes for time series feature engineering and transformation,
-including autoregressive features, differencing, seasonality, and trend decomposition.
-"""
-
-__author__ = 'Adelson Araujo'
-
-from abc import abstractmethod
-from numpy import vstack
-import pandas as pd
-from sklearn.base import BaseEstimator, TransformerMixin
-from statsmodels.tsa.seasonal import STL
-
-
-
-[docs] -class TimeSeriesFeatures(BaseEstimator, TransformerMixin): - """ - Base class for time series feature engineering. - - Args: - lags (int): Number of time lags to use for feature creation - tfreq (str): Time frequency ('D' for daily, 'W' for weekly, 'M' for monthly) - debug (bool, optional): Enable debug printing. Defaults to False - - Raises: - AssertionError: If lags is not a positive integer or tfreq is invalid - """ - - def __init__(self, lags, tfreq, debug=False): - self.debug = debug - if self.debug: - print(f"Initializing TimeSeriesFeatures with lags={lags}, freq={tfreq}", flush=True) - - assert isinstance(lags, int) and lags > 1, \ - '`lags` must be a positive integer.' - self._lags = lags - assert tfreq in ['D', 'W', 'M'], \ - '`tfreq` not allowed, choose between `D`, `W`, `M`.' - self._tfreq = tfreq - - @property - def lags(self): - """int: Number of time lags""" - return self._lags - - @property - def label(self): - """str: Feature label identifier""" - return 'feature' # override this label if implementing a new feature - -
-[docs] - @abstractmethod - def apply_ts_decomposition(self, ts): - """ - Apply time series decomposition. - - Args: - ts (pandas.Series): Input time series - - Returns: - pandas.Series: Transformed time series - """ - pass
- - -
-[docs] - def make_lag_df(self, ts): - """ - Create lagged features dataframe. - - Args: - ts (pandas.Series): Input time series - - Returns: - tuple: (lag_df, aligned_ts) - Lagged features and aligned original series - - Raises: - AssertionError: If series length is less than number of lags - """ - if self.debug: - print(f"Creating lag features for series of length {len(ts)}", flush=True) - - assert len(ts) > self.lags, "`lags` are higher than temporal units." - lag_df = pd.concat([ts.shift(lag) for lag in range(1, self.lags+1)], axis=1) - lag_df = lag_df.iloc[self.lags:] - lag_df.columns = ['{}_{}'.format(self.label, i) for i in range(1, self.lags+1)] - return lag_df, ts.loc[lag_df.index]
- - -
-[docs] - def transform(self, stseries): - """ - Transform the input series into lagged features. - - Args: - stseries (pandas.Series): Input time series with multi-index (time, places) - - Returns: - pandas.DataFrame: Transformed features - """ - if self.debug: - print(f"Transforming series with {len(stseries)} observations", flush=True) - - X = pd.DataFrame() - if self._tfreq=='M': - next_time = pd.tseries.offsets.MonthEnd(1) - elif self._tfreq=='W': - next_time = pd.tseries.offsets.Week(1) - elif self._tfreq=='D': - next_time = pd.tseries.offsets.Day(1) - - places = stseries.index.get_level_values('places').unique() - for place in places: - if self.debug: - print(f"Processing features for place: {place}", flush=True) - - ts = stseries.loc[pd.IndexSlice[:, place]] - ts = self.apply_ts_decomposition(ts) - ts.loc[ts.index[-1] + next_time] = None - f, _ = self.make_lag_df(ts) - f['places'] = place - f = f.set_index('places', append=True) - X = X.append(f) - X = X.sort_index() - return X
-
- - - -
-[docs] -class AR(TimeSeriesFeatures): - """ - Autoregressive features implementation. - """ - - @property - def label(self): - """str: Feature label for autoregressive features""" - return 'ar' - -
-[docs] - def apply_ts_decomposition(self, ts): - """ - Apply autoregressive transformation (identity). - - Args: - ts (pandas.Series): Input time series - - Returns: - pandas.Series: Original time series - """ - return ts
-
- - - -
-[docs] -class Diff(TimeSeriesFeatures): - """ - Difference features implementation. - """ - - @property - def label(self): - """str: Feature label for difference features""" - return 'diff' - -
-[docs] - def apply_ts_decomposition(self, ts): - """ - Apply difference transformation. - - Args: - ts (pandas.Series): Input time series - - Returns: - pandas.Series: Differenced time series - """ - return ts.diff()[1:]
-
- - - -
-[docs] -class Seasonality(TimeSeriesFeatures): - """ - Seasonal decomposition features implementation. - """ - - @property - def label(self): - """str: Feature label for seasonal features""" - return 'seasonal' - -
-[docs] - def apply_ts_decomposition(self, ts): - """ - Extract seasonal component from time series. - - Args: - ts (pandas.Series): Input time series - - Returns: - pandas.Series: Seasonal component - """ - if self.debug: - print(f"Extracting seasonality with period={self._lags}", flush=True) - return STL(ts, period=self._lags).seasonal
-
- - - -
-[docs] -class Trend(TimeSeriesFeatures): - """ - Trend decomposition features implementation. - """ - - @property - def label(self): - """str: Feature label for trend features""" - return 'trend' - -
-[docs] - def apply_ts_decomposition(self, ts): - """ - Extract trend component from time series. - - Args: - ts (pandas.Series): Input time series - - Returns: - pandas.Series: Trend component - """ - if self.debug: - print(f"Extracting trend with period={self._lags}", flush=True) - return STL(ts, period=self._lags).trend
-
- - - -
-[docs] -class FeatureScaling(TransformerMixin, BaseEstimator): - """ - Feature scaling transformer. - - Args: - estimator: Scikit-learn compatible scaling estimator - debug (bool, optional): Enable debug printing. Defaults to False - """ - - def __init__(self, estimator, debug=False): - self.debug = debug - self._estimator = estimator - - if self.debug: - print("Initializing FeatureScaling", flush=True) - -
-[docs] - def transform(self, x): - """ - Transform features using the scaling estimator. - - Args: - x (pandas.DataFrame): Input features - - Returns: - pandas.DataFrame: Scaled features - """ - if self.debug: - print(f"Scaling features of shape {x.shape}", flush=True) - - return pd.DataFrame( - self._estimator.transform(x), - index=x.index, - columns=x.columns - )
-
- -
- -
- - -
-
-
-
- - - - - - - \ No newline at end of file diff --git a/docs/build/html/_modules/predspot/ml_modelling.html b/docs/build/html/_modules/predspot/ml_modelling.html deleted file mode 100644 index 7a072a5..0000000 --- a/docs/build/html/_modules/predspot/ml_modelling.html +++ /dev/null @@ -1,453 +0,0 @@ - - - - - - - predspot.ml_modelling — Predspot 1.0 documentation - - - - - - - - - - - - - - - - - -
- - -
-
- - - -
- -

Source code for predspot.ml_modelling

-"""
-Machine Learning Modelling Module
-
-This module provides classes for machine learning model pipelines, feature selection,
-and prediction functionality for crime density forecasting.
-"""
-
-__author__ = 'Adelson Araujo'
-
-import pandas as pd
-import numpy as np
-from sklearn.base import BaseEstimator, TransformerMixin, RegressorMixin
-from sklearn.pipeline import Pipeline
-from sklearn.metrics import r2_score, mean_squared_error
-from sklearn.model_selection import TimeSeriesSplit
-
-idx = pd.IndexSlice
-
-
-
-[docs] -class FeatureSelection(TransformerMixin, BaseEstimator): - """ - Feature selection transformer. - - Args: - estimator: Scikit-learn compatible feature selector - debug (bool, optional): Enable debug printing. Defaults to False - """ - - def __init__(self, estimator, debug=False): - self._estimator = estimator - self.debug = debug - - if self.debug: - print("Initializing FeatureSelection", flush=True) - -
-[docs] - def fit(self, x, y=None): - """ - Fit the feature selector. - - Args: - x (pandas.DataFrame): Input features - y (pandas.Series, optional): Target variable - - Returns: - self: The fitted instance - """ - if self.debug: - print(f"Fitting feature selector with {x.shape[1]} features", flush=True) - - self._estimator.fit(x, y) - return self
- - -
-[docs] - def transform(self, x): - """ - Transform features using the feature selector. - - Args: - x (pandas.DataFrame): Input features - - Returns: - pandas.DataFrame: Selected features - """ - if self.debug: - print(f"Transforming features, selecting {sum(self._estimator.support_)} features", flush=True) - - return pd.DataFrame( - self._estimator.transform(x), - index=x.index, - columns=x.columns[self._estimator.support_] - )
-
- - - -
-[docs] -class Model(RegressorMixin, BaseEstimator): - """ - Model wrapper for crime density prediction. - - Args: - estimator: Scikit-learn compatible regression estimator - debug (bool, optional): Enable debug printing. Defaults to False - """ - - def __init__(self, estimator, debug=False): - self._estimator = estimator - self.debug = debug - - if self.debug: - print("Initializing Model wrapper", flush=True) - -
-[docs] - def fit(self, x, y=None): - """ - Fit the regression model. - - Args: - x (pandas.DataFrame): Input features - y (pandas.Series, optional): Target variable - - Returns: - self: The fitted instance - """ - if self.debug: - print(f"Fitting model with {x.shape[1]} features", flush=True) - - self._estimator.fit(x, y) - return self
- - -
-[docs] - def predict(self, x): - """ - Make predictions using the fitted model. - - Args: - x (pandas.DataFrame): Input features - - Returns: - pandas.DataFrame: Predictions with 'crime_density' column - """ - if self.debug: - print(f"Making predictions for {len(x)} instances", flush=True) - - return pd.DataFrame( - self._estimator.predict(x), - index=x.index, - columns=['crime_density'] - )
-
- - - -
-[docs] -class PredictionPipeline(RegressorMixin, BaseEstimator): - """ - Complete pipeline for crime density prediction. - - Args: - mapping: Spatial mapping transformer - fextraction: Feature extraction transformer - estimator: Scikit-learn compatible pipeline or estimator - debug (bool, optional): Enable debug printing. Defaults to False - """ - - def __init__(self, mapping, fextraction, estimator, debug=False): - self._mapping = mapping - self._fextraction = fextraction - self._estimator = estimator - self._stseries = None - self._dataset = None - self.debug = debug - - if self.debug: - print("Initializing PredictionPipeline", flush=True) - - if mapping._tfreq == 'M': - self._offset = pd.tseries.offsets.MonthEnd(1) - elif mapping._tfreq == 'W': - self._offset = pd.tseries.offsets.Week(1) - elif mapping._tfreq == 'D': - self._offset = pd.tseries.offsets.Day(1) - - @property - def grid(self): - """GeoDataFrame: Spatial grid used for mapping""" - return self._mapping._grid - - @property - def dataset(self): - """Dataset: Current dataset being used""" - return self._dataset - - @property - def stseries(self): - """pandas.Series: Spatio-temporal series""" - return self._stseries - - @property - def feature_importances(self): - """ - Get feature importance scores. - - Returns: - pandas.DataFrame: Feature importance scores - - Raises: - Exception: If model hasn't been fitted or doesn't support feature importances - """ - assert self._X is not None, 'this instance was not fitted yet.' - try: - return pd.DataFrame( - self._estimator.steps[-1][1]._estimator.feature_importances_, - index=self._X.columns[self._estimator.steps[-2][1]._estimator.support_], - columns=['importance'] - ) - except: - raise Exception('estimator used has not feature importances implemented yet.') - -
-[docs] - def evaluate(self, scoring, cv=5): - """ - Evaluate model performance using time series cross-validation. - - Args: - scoring (str): Scoring metric ('r2' or 'mse') - cv (int): Number of cross-validation folds - - Returns: - list: Scores for each fold - - Raises: - Exception: If scoring metric is invalid - """ - if self.debug: - print(f"Evaluating model with {cv}-fold time series CV", flush=True) - - assert self._X is not None, 'this instance was not fitted yet.' - if scoring == 'r2': - scoring = r2_score - elif scoring == 'mse': - scoring = mean_squared_error - else: - raise Exception('invalid scoring. Try "r2" or "mse".') - - timestamps = self._X.index.get_level_values('t').unique()\ - .intersection(self._stseries.index.get_level_values('t').unique()) - assert isinstance(cv, int) and cv < len(timestamps), \ - 'cv must be an integer and not higher than the number of timestamps available.' - - scores = [] - for train_t, test_t in TimeSeriesSplit(cv).split(timestamps): - if self.debug: - print(f"CV fold - train size: {len(train_t)}, test size: {len(test_t)}", flush=True) - - X_train = self._X.loc[idx[timestamps[train_t], :], :].sample(frac=1) - X_test = self._X.loc[idx[timestamps[test_t], :], :] - y_train = self._stseries.loc[X_train.index] - y_test = self._stseries.loc[timestamps[test_t]] - self._estimator.fit(X_train, y_train) - y_pred = self._estimator.predict(X_test) - scores.append(scoring(y_test, y_pred)) - - self.fit(self._dataset) # back to normal - return scores
- - -
-[docs] - def fit(self, dataset, y=None): - """ - Fit the complete prediction pipeline. - - Args: - dataset: Input dataset containing crimes and study area - y: Ignored, present for scikit-learn compatibility - - Returns: - self: The fitted instance - - Raises: - Exception: If fitting fails - """ - if self.debug: - print("Fitting prediction pipeline", flush=True) - - self._dataset = dataset - self._stseries = self._mapping.fit_transform(dataset.crimes) - self._X = self._fextraction.fit_transform(self._stseries) - t0 = self._X.index.get_level_values('t').unique().min() - tf = self._stseries.index.get_level_values('t').unique().max() - X = self._X.loc[t0:tf].sample(frac=1) # shuffle for training - y = self._stseries.loc[X.index] # shuffle for training - - try: - self._estimator.fit(X, y) - except Exception as e: - raise Exception(f'ERROR: {t0}, {tf} \nX: {self._X.loc[t0:tf]}') from e - - self._t_plus_one = self._X.index.get_level_values('t').unique()[-1] - return self
- - -
-[docs] - def predict(self): - """ - Make predictions for the next time step. - - Returns: - pandas.DataFrame: Predictions for next time step - """ - if self.debug: - print(f"Predicting for time step: {self._t_plus_one}", flush=True) - - X = self._X.loc[[self._t_plus_one],:] - y_pred = self._estimator.predict(X) - y_pred = pd.DataFrame(y_pred, index=X.index) - self._stseries = self._stseries.append(y_pred['crime_density']) - self._X = self._fextraction.transform(self._stseries) - self._t_plus_one += self._offset - return y_pred
-
- -
- -
- - -
-
-
-
- - - - - - - \ No newline at end of file diff --git a/docs/build/html/_modules/predspot/pipeline.html b/docs/build/html/_modules/predspot/pipeline.html deleted file mode 100644 index 2f8bdb5..0000000 --- a/docs/build/html/_modules/predspot/pipeline.html +++ /dev/null @@ -1,330 +0,0 @@ - - - - - - - predspot.pipeline — Predspot 1.0 documentation - - - - - - - - - - - - - - - - - -
- - -
-
- - - -
- -

Source code for predspot.pipeline

-"""
-Pipeline Module
-
-This module provides the main pipeline functionality for crime prediction,
-including data loading, preprocessing, and model execution.
-
-Example:
-    >>> from predspot.pipeline import generate_testdata, run_prediction_pipeline
-    >>> crime_data, study_area = generate_testdata(10000, '2020-01-01', '2020-12-31')
-    >>> results = run_prediction_pipeline(crime_data, study_area)
-"""
-
-__author__ = 'Adelson Araujo'
-
-import numpy as np
-import pandas as pd
-import geopandas as gpd
-from sklearn.pipeline import Pipeline
-from sklearn.preprocessing import QuantileTransformer
-from sklearn.feature_selection import RFE
-from sklearn.ensemble import RandomForestRegressor
-
-from predspot import dataset_preparation
-from predspot import crime_mapping
-from predspot import feature_engineering
-from predspot import ml_modelling
-from predspot.utilities import PandasFeatureUnion
-
-
-
-[docs] -def generate_testdata(n_points, start_time, end_time, debug=False): - """ - Generate synthetic crime data for testing. - - Args: - n_points (int): Number of crime incidents to generate - start_time (str): Start date in 'YYYY-MM-DD' format - end_time (str): End date in 'YYYY-MM-DD' format - debug (bool, optional): Enable debug printing. Defaults to False - - Returns: - tuple: (crimes_df, study_area_gdf) - Generated crime data and study area - - Example: - >>> crimes, area = generate_testdata(1000, '2020-01-01', '2020-12-31') - """ - if debug: - print(f"Generating {n_points} test data points from {start_time} to {end_time}", flush=True) - - study_area = gpd.read_file(gpd.datasets.get_path('naturalearth_lowres')) - study_area = study_area.loc[study_area['name']=='Brazil'] - - crimes = pd.DataFrame() - crime_types = pd.Series(['burglary', 'assault', 'drugs', 'homicide']) - bounds = study_area.geometry.bounds.values[0] - - if debug: - print("Generating random dates and locations", flush=True) - - def random_dates(start, end, n=10): - """Generate random dates within a range.""" - start, end = pd.to_datetime(start), pd.to_datetime(end) - start_u = start.value//10**9 - end_u = end.value//10**9 - return pd.to_datetime(np.random.randint(start_u, end_u, n), unit='s') - - crimes['tag'] = crime_types.sample(n_points, replace=True, - weights=[1000, 100, 10, 1]) - crimes['t'] = random_dates(start_time, end_time, n_points) - crimes['lat'] = np.random.uniform(bounds[1], bounds[3], n_points) - crimes['lon'] = np.random.uniform(bounds[0], bounds[2], n_points) - crimes.reset_index(drop=True, inplace=True) - - if debug: - print("Test data generation complete", flush=True) - - return crimes, study_area
- - - -
-[docs] -def run_prediction_pipeline(crime_data, study_area, crime_tags=None, time_range=None, - tfreq='M', grid_resolution=250, debug=False): - """ - Run the complete crime prediction pipeline. - - Args: - crime_data (pandas.DataFrame): Crime incident data - study_area (geopandas.GeoDataFrame): Study area boundaries - crime_tags (list, optional): List of crime types to include - time_range (list, optional): Time range as ['HH:MM', 'HH:MM'] - tfreq (str, optional): Time frequency ('M', 'W', 'D'). Defaults to 'M' - grid_resolution (float, optional): Spatial grid resolution in km. Defaults to 250 - debug (bool, optional): Enable debug printing. Defaults to False - - Returns: - tuple: (predictions, pipeline) - Predicted crime densities and fitted pipeline - - Raises: - ValueError: If input data is invalid or missing required columns - """ - if debug: - print("Initializing prediction pipeline", flush=True) - - # Validate input data - required_columns = ['tag', 't', 'lat', 'lon'] - if not all(col in crime_data.columns for col in required_columns): - raise ValueError(f"Crime data must contain columns: {required_columns}") - - # Filter by crime tags if specified - if crime_tags: - if debug: - print(f"Filtering for crime types: {crime_tags}", flush=True) - crime_data = crime_data.loc[crime_data['tag'].isin(crime_tags)] - - # Filter by time range if specified - if time_range: - if debug: - print(f"Filtering for time range: {time_range}", flush=True) - time_ix = pd.DatetimeIndex(crime_data['t']) - crime_data = crime_data.iloc[time_ix.indexer_between_time(time_range[0], time_range[1])] - - if debug: - print("Creating dataset", flush=True) - dataset = dataset_preparation.Dataset(crimes=crime_data, study_area=study_area) - - if debug: - print("Building prediction pipeline", flush=True) - pred_pipeline = ml_modelling.PredictionPipeline( - mapping=crime_mapping.KDE( - tfreq=tfreq, - bandwidth='auto', - grid=crime_mapping.create_gridpoints(study_area, grid_resolution) - ), - fextraction=PandasFeatureUnion([ - ('seasonal', feature_engineering.Seasonality(lags=2)), - ('trend', feature_engineering.Trend(lags=2)), - ('diff', feature_engineering.Diff(lags=2)) - ]), - estimator=Pipeline([ - ('f_scaling', feature_engineering.FeatureScaling( - QuantileTransformer(10, output_distribution='uniform'))), - ('f_selection', ml_modelling.FeatureSelection( - RFE(RandomForestRegressor()))), - ('model', ml_modelling.Model(RandomForestRegressor(n_estimators=50))) - ]) - ) - - if debug: - print("Fitting pipeline", flush=True) - pred_pipeline.fit(dataset) - - if debug: - print("Making predictions", flush=True) - predictions = pred_pipeline.predict() - - if debug: - print("Pipeline execution complete", flush=True) - - return predictions, pred_pipeline
- - - -
-[docs] -def evaluate_pipeline(pipeline, scoring='r2', cv=5, debug=False): - """ - Evaluate the prediction pipeline using cross-validation. - - Args: - pipeline (PredictionPipeline): Fitted prediction pipeline - scoring (str, optional): Scoring metric ('r2' or 'mse'). Defaults to 'r2' - cv (int, optional): Number of cross-validation folds. Defaults to 5 - debug (bool, optional): Enable debug printing. Defaults to False - - Returns: - list: Cross-validation scores - - Example: - >>> scores = evaluate_pipeline(fitted_pipeline, scoring='r2', cv=5) - """ - if debug: - print(f"Evaluating pipeline with {cv}-fold CV using {scoring} metric", flush=True) - - scores = pipeline.evaluate(scoring=scoring, cv=cv) - - if debug: - print(f"Evaluation complete. Mean score: {np.mean(scores):.4f}", flush=True) - - return scores
- -
- -
- - -
-
-
-
- - - - - - - \ No newline at end of file diff --git a/docs/build/html/_modules/predspot/utilities.html b/docs/build/html/_modules/predspot/utilities.html deleted file mode 100644 index f6245fb..0000000 --- a/docs/build/html/_modules/predspot/utilities.html +++ /dev/null @@ -1,336 +0,0 @@ - - - - - - - predspot.utilities — Predspot 1.0 documentation - - - - - - - - - - - - - - - - - -
- - -
-
- - - -
- -

Source code for predspot.utilities

-"""
-Utilities Module
-
-This module provides utility functions and classes for data processing and visualization,
-including contour generation, feature union operations, and pandas-specific transformations.
-"""
-
-__author__ = 'Adelson Araujo'
-
-import matplotlib
-matplotlib.use('Agg')
-import matplotlib.pyplot as plt
-import pandas as pd
-from geopandas import GeoDataFrame
-from numpy import zeros, linspace, ceil, meshgrid
-from sklearn.pipeline import FeatureUnion, Pipeline, _fit_transform_one, _transform_one
-from joblib import Parallel, delayed
-from scipy import sparse
-import geojsoncontour
-
-
-
-[docs] -def contour_geojson(y, bbox, resolution, cmin, cmax, debug=False): - """ - Generate GeoJSON contours from spatial data. - - Args: - y (pandas.Series): Values to contour - bbox (GeoDataFrame): Bounding box for the contour - resolution (float): Spatial resolution in kilometers - cmin (float): Minimum contour value - cmax (float): Maximum contour value - debug (bool, optional): Enable debug printing. Defaults to False - - Returns: - dict: GeoJSON representation of the contours - - Raises: - AssertionError: If bbox is not a GeoDataFrame - """ - if debug: - print(f"Generating contours with resolution: {resolution}km", flush=True) - - assert isinstance(bbox, GeoDataFrame) - bounds = bbox.bounds - b_s, b_w = bounds.min().values[1], bounds.min().values[0] - b_n, b_e = bounds.max().values[3], bounds.max().values[2] - - # Calculate grid dimensions - nlon = int(ceil((b_e-b_w) / (resolution/111.32))) - nlat = int(ceil((b_n-b_s) / (resolution/110.57))) - - if debug: - print(f"Grid dimensions: {nlon}x{nlat}", flush=True) - - # Create meshgrid and initialize values - lonv, latv = meshgrid(linspace(b_w, b_e, nlon), linspace(b_s, b_n, nlat)) - Z = zeros(lonv.shape[0]*lonv.shape[1]) - 999 - Z[y.index] = y.values - Z = Z.reshape(lonv.shape) - - # Generate contours - fig, axes = plt.subplots() - contourf = axes.contourf(lonv, latv, Z, - levels=linspace(cmin, cmax, 25), - cmap='Spectral_r') - - if debug: - print("Converting contours to GeoJSON", flush=True) - - geojson = geojsoncontour.contourf_to_geojson(contourf=contourf, fill_opacity=0.5) - plt.close(fig) - return geojson
- - - -
-[docs] -class PandasFeatureUnion(FeatureUnion): - """ - A FeatureUnion transformer that preserves pandas DataFrames. - - This class extends sklearn's FeatureUnion to work with pandas DataFrames, - maintaining index alignment and column names. - - Attributes: - n_jobs (int): Number of parallel jobs - transformer_list (list): List of transformer tuples - transformer_weights (dict): Weights for transformers - debug (bool): Enable debug printing - """ - - def __init__(self, transformer_list, n_jobs=None, transformer_weights=None, debug=False): - super().__init__(transformer_list, n_jobs, transformer_weights) - self.debug = debug - - if self.debug: - print("Initializing PandasFeatureUnion", flush=True) - -
-[docs] - def fit_transform(self, X, y=None, **fit_params): - """ - Fit all transformers and transform the data. - - Args: - X (pandas.DataFrame): Input features - y (array-like, optional): Target values - **fit_params: Additional fitting parameters - - Returns: - pandas.DataFrame: Transformed features - - Raises: - ValueError: If no transformers are provided - """ - if self.debug: - print(f"Fitting and transforming {len(X)} samples", flush=True) - - self._validate_transformers() - result = Parallel(n_jobs=self.n_jobs)( - delayed(_fit_transform_one)( - transformer=trans, - X=X, - y=y, - weight=weight, - **fit_params) - for name, trans, weight in self._iter()) - - if not result: - # All transformers are None - return zeros((X.shape[0], 0)) - - Xs, transformers = zip(*result) - self._update_transformer_list(transformers) - - if self.debug: - print("Merging transformed features", flush=True) - - if any(sparse.issparse(f) for f in Xs): - Xs = sparse.hstack(Xs).tocsr() - else: - Xs = self.merge_dataframes_by_column(Xs) - return Xs
- - -
-[docs] - def merge_dataframes_by_column(self, Xs): - """ - Merge transformed features into a single DataFrame. - - Args: - Xs (list): List of transformed DataFrames - - Returns: - pandas.DataFrame: Merged DataFrame - """ - if self.debug: - print(f"Merging {len(Xs)} DataFrames", flush=True) - - return pd.concat(Xs, axis="columns", copy=False).dropna()
- - -
-[docs] - def transform(self, X): - """ - Transform X separately by each transformer. - - Args: - X (pandas.DataFrame): Input features - - Returns: - pandas.DataFrame: Transformed features - """ - if self.debug: - print(f"Transforming features with {len(self.transformer_list)} transformers", flush=True) - - Xs = Parallel(n_jobs=self.n_jobs)( - delayed(_transform_one)( - transformer=trans, - X=X, - y=None, - weight=weight) - for name, trans, weight in self._iter()) - - if not Xs: - # All transformers are None - return zeros((X.shape[0], 0)) - - if any(sparse.issparse(f) for f in Xs): - Xs = sparse.hstack(Xs).tocsr() - else: - Xs = self.merge_dataframes_by_column(Xs) - return Xs
-
- -
- -
- - -
-
-
-
- - - - - - - \ No newline at end of file diff --git a/docs/build/html/_sources/index.rst.txt b/docs/build/html/_sources/index.rst.txt deleted file mode 100644 index 3729b47..0000000 --- a/docs/build/html/_sources/index.rst.txt +++ /dev/null @@ -1,177 +0,0 @@ -Predspot -======== - -Overview --------- - -Predspot is an early project for a Python library for spatio-temporal crime prediction and hotspot detection. It combines machine learning techniques with spatial analysis to help predict and visualize crime patterns across time and space. To properly use this library, you preferably need to have a crime dataset and a study area. - -The results of this library are not guaranteed to be good, but it is a good starting point for spatio-temporal crime prediction. This project is barely maintained, so please if you want to collaborate, open an issue or a PR. - -Important Notice ----------------- - -.. warning:: - This project was developed as part of a master's thesis and is currently in an archived state. While the core functionality exists, you may encounter compatibility issues with newer Python package versions. The code can work with some effort, but please note: - - * This is not production-ready software - * Some dependencies are outdated and may require specific versions - * You might need to modify some code to work with newer package versions - * The project was created for research purposes - - However, we believe the methodologies and approaches used here are still valuable! If you're interested in crime hotspot prediction, feel free to: - - * Use this as a reference implementation - * Adapt the code to modern dependencies - * Build upon these concepts for your own projects - * Contribute to modernizing the codebase - - We welcome anyone interested in reviving or learning from this project! - -Key Features ------------- -* Spatial and temporal crime mapping -* Feature engineering for time series data -* Machine learning-based prediction pipeline -* Crime hotspot detection using Kernel Density Estimation -* Visualization tools for crime patterns - -Quick Start ------------ - -Basic usage example: - -.. code-block:: python - - from predspot import Dataset, PredictionPipeline - from predspot.crime_mapping import KDE, create_gridpoints - from predspot.feature_engineering import Seasonality, Trend, Diff - - # Load and prepare data - dataset = Dataset(crimes_df, study_area_gdf) - - # Create prediction pipeline - pipeline = PredictionPipeline( - mapping=KDE(tfreq='M', grid=create_gridpoints(study_area, resolution=250)), - fextraction=PandasFeatureUnion([ - ('seasonal', Seasonality(lags=12)), - ('trend', Trend(lags=12)), - ('diff', Diff(lags=12)) - ]), - estimator=your_favorite_sklearn_model - ) - - # Fit and predict - pipeline.fit(dataset) - predictions = pipeline.predict() - -Modules -------- - -The library consists of four main modules: - -* :doc:`modules/dataset_preparation`: Module for preparing and managing crime datasets and study areas. -* :doc:`modules/crime_mapping`: Module for spatial and temporal crime mapping, including KDE-based hotspot detection. -* :doc:`modules/feature_engineering`: Module for time series feature engineering, including seasonality, trend, and difference features. -* :doc:`modules/ml_modelling`: Module that implements the prediction pipeline and model evaluation. - -And two utilities: - -* :doc:`modules/utilities`: Utility functions for data preparation and visualization. -* :doc:`modules/pipeline`: Functions for the prediction pipeline. - -Installation ------------- - -Create conda env and install requirements: - -.. code-block:: bash - - conda create -n predspot python=3.8 - conda activate predspot - conda install -y rtree geopandas # if doesnt work, do: `conda clean --all` - pip install pandas statsmodels==0.10.2 geojsoncontour stldecompose scikit-learn matplotlib descartes - pip install . - -Required dependencies: - -* pandas -* geopandas -* numpy -* scikit-learn -* scipy -* stldecompose -* matplotlib - -Input Data Format ------------------ - -The crime data should be a pandas DataFrame with the following required columns: - -* ``tag``: Crime type -* ``t``: Timestamp -* ``lon``: Longitude -* ``lat``: Latitude - -The study area should be a GeoDataFrame defining the geographical boundaries of interest. - -Resources ---------- - -For more information on the methods used in Predspot, please search more about these methods: - -* Kernel Density Estimation for crime hotspot detection -* Time series decomposition for feature engineering -* Spatio-temporal crime prediction techniques - -License -------- - -BSD 3-Clause. - -Contributing ------------- - -Contributions are welcome! Please feel free to submit a Pull Request. - -Guidelines for contributing: - -1. Fork the repository -2. Create your feature branch -3. Commit your changes -4. Push to the branch -5. Create a new Pull Request - -Citation --------- - -If you use Predspot in your research, please cite us: - -.. code-block:: text - - Araujo, A., & Cacho, N. (2019). Predspot: Predicting crime hotspots with machine learning. - Master's thesis, UFRN (Universidade Federal do Rio Grande do Norte), Natal, Brazil. - - Araújo, A., Cacho, N., Bezerra, L., Vieira, C., & Borges, J. (2018, June). - Towards a crime hotspot detection framework for patrol planning. - In 2018 IEEE 20th International Conference on High Performance Computing and Communications; - IEEE 16th International Conference on Smart City; - IEEE 4th International Conference on Data Science and Systems (HPCC/SmartCity/DSS) (pp. 1256-1263). IEEE. - -.. toctree:: - :maxdepth: 2 - :caption: Documentation: - - modules/dataset_preparation - modules/crime_mapping - modules/feature_engineering - modules/ml_modelling - modules/utilities - modules/pipeline - -Indices and tables -================== - -* :ref:`genindex` -* :ref:`modindex` -* :ref:`search` \ No newline at end of file diff --git a/docs/build/html/_sources/modules/crime_mapping.rst.txt b/docs/build/html/_sources/modules/crime_mapping.rst.txt deleted file mode 100644 index 4feb87a..0000000 --- a/docs/build/html/_sources/modules/crime_mapping.rst.txt +++ /dev/null @@ -1,9 +0,0 @@ -Crime Mapping -============= - -.. automodule:: predspot.crime_mapping - :members: - :undoc-members: - :show-inheritance: - :no-index: - diff --git a/docs/build/html/_sources/modules/dataset_preparation.rst.txt b/docs/build/html/_sources/modules/dataset_preparation.rst.txt deleted file mode 100644 index ef4952c..0000000 --- a/docs/build/html/_sources/modules/dataset_preparation.rst.txt +++ /dev/null @@ -1,8 +0,0 @@ -Dataset Preparation -=================== - -.. automodule:: predspot.dataset_preparation - :members: - :undoc-members: - :show-inheritance: - :no-index: diff --git a/docs/build/html/_sources/modules/feature_engineering.rst.txt b/docs/build/html/_sources/modules/feature_engineering.rst.txt deleted file mode 100644 index 1d4e0c2..0000000 --- a/docs/build/html/_sources/modules/feature_engineering.rst.txt +++ /dev/null @@ -1,9 +0,0 @@ -Feature Engineering -=================== - -.. automodule:: predspot.feature_engineering - :members: - :undoc-members: - :show-inheritance: - :no-index: - diff --git a/docs/build/html/_sources/modules/ml_modelling.rst.txt b/docs/build/html/_sources/modules/ml_modelling.rst.txt deleted file mode 100644 index d272c45..0000000 --- a/docs/build/html/_sources/modules/ml_modelling.rst.txt +++ /dev/null @@ -1,9 +0,0 @@ -ML Modelling -============ - -.. automodule:: predspot.ml_modelling - :members: - :undoc-members: - :show-inheritance: - :no-index: - diff --git a/docs/build/html/_sources/modules/pipeline.rst.txt b/docs/build/html/_sources/modules/pipeline.rst.txt deleted file mode 100644 index 4a43db0..0000000 --- a/docs/build/html/_sources/modules/pipeline.rst.txt +++ /dev/null @@ -1,9 +0,0 @@ -Pipeline -======== - -.. automodule:: predspot.pipeline - :members: - :undoc-members: - :show-inheritance: - :no-index: - diff --git a/docs/build/html/_sources/modules/utilities.rst.txt b/docs/build/html/_sources/modules/utilities.rst.txt deleted file mode 100644 index c99dc1a..0000000 --- a/docs/build/html/_sources/modules/utilities.rst.txt +++ /dev/null @@ -1,9 +0,0 @@ -Utilities -========= - -.. automodule:: predspot.utilities - :members: - :undoc-members: - :show-inheritance: - :no-index: - diff --git a/docs/build/html/_static/alabaster.css b/docs/build/html/_static/alabaster.css deleted file mode 100644 index 96fa422..0000000 --- a/docs/build/html/_static/alabaster.css +++ /dev/null @@ -1,668 +0,0 @@ -/* -- page layout ----------------------------------------------------------- */ - -body { - font-family: Georgia, serif; - font-size: 17px; - background-color: #fff; - color: #000; - margin: 0; - padding: 0; -} - - -div.document { - width: 1000px; - margin: 30px auto 0 auto; -} - -div.documentwrapper { - float: left; - width: 100%; -} - -div.bodywrapper { - margin: 0 0 0 250px; -} - -div.sphinxsidebar { - width: 250px; - font-size: 14px; - line-height: 1.5; -} - -hr { - border: 1px solid #B1B4B6; -} - -div.body { - background-color: #fff; - color: #3E4349; - padding: 0 30px 0 30px; -} - -div.body > .section { - text-align: left; -} - -div.footer { - width: 1000px; - margin: 20px auto 30px auto; - font-size: 14px; - color: #888; - text-align: right; -} - -div.footer a { - color: #888; -} - -p.caption { - font-family: inherit; - font-size: inherit; -} - - -div.relations { - display: none; -} - - -div.sphinxsidebar { - max-height: 100%; - overflow-y: auto; -} - -div.sphinxsidebar a { - color: #444; - text-decoration: none; - border-bottom: 1px dotted #999; -} - -div.sphinxsidebar a:hover { - border-bottom: 1px solid #999; -} - -div.sphinxsidebarwrapper { - padding: 18px 10px; -} - -div.sphinxsidebarwrapper p.logo { - padding: 0; - margin: -10px 0 0 0px; - text-align: center; -} - -div.sphinxsidebarwrapper h1.logo { - margin-top: -10px; - text-align: center; - margin-bottom: 5px; - text-align: left; -} - -div.sphinxsidebarwrapper h1.logo-name { - margin-top: 0px; -} - -div.sphinxsidebarwrapper p.blurb { - margin-top: 0; - font-style: normal; -} - -div.sphinxsidebar h3, -div.sphinxsidebar h4 { - font-family: Georgia, serif; - color: #444; - font-size: 24px; - font-weight: normal; - margin: 0 0 5px 0; - padding: 0; -} - -div.sphinxsidebar h4 { - font-size: 20px; -} - -div.sphinxsidebar h3 a { - color: #444; -} - -div.sphinxsidebar p.logo a, -div.sphinxsidebar h3 a, -div.sphinxsidebar p.logo a:hover, -div.sphinxsidebar h3 a:hover { - border: none; -} - -div.sphinxsidebar p { - color: #555; - margin: 10px 0; -} - -div.sphinxsidebar ul { - margin: 10px 0; - padding: 0; - color: #000; -} - -div.sphinxsidebar ul li.toctree-l1 > a { - font-size: 120%; -} - -div.sphinxsidebar ul li.toctree-l2 > a { - font-size: 110%; -} - -div.sphinxsidebar input { - border: 1px solid #CCC; - font-family: Georgia, serif; - font-size: 1em; -} - -div.sphinxsidebar #searchbox { - margin: 1em 0; -} - -div.sphinxsidebar .search > div { - display: table-cell; -} - -div.sphinxsidebar hr { - border: none; - height: 1px; - color: #AAA; - background: #AAA; - - text-align: left; - margin-left: 0; - width: 50%; -} - -div.sphinxsidebar .badge { - border-bottom: none; -} - -div.sphinxsidebar .badge:hover { - border-bottom: none; -} - -/* To address an issue with donation coming after search */ -div.sphinxsidebar h3.donation { - margin-top: 10px; -} - -/* -- body styles ----------------------------------------------------------- */ - -a { - color: #004B6B; - text-decoration: underline; -} - -a:hover { - color: #6D4100; - text-decoration: underline; -} - -div.body h1, -div.body h2, -div.body h3, -div.body h4, -div.body h5, -div.body h6 { - font-family: Georgia, serif; - font-weight: normal; - margin: 30px 0px 10px 0px; - padding: 0; -} - -div.body h1 { margin-top: 0; padding-top: 0; font-size: 240%; } -div.body h2 { font-size: 180%; } -div.body h3 { font-size: 150%; } -div.body h4 { font-size: 130%; } -div.body h5 { font-size: 100%; } -div.body h6 { font-size: 100%; } - -a.headerlink { - color: #DDD; - padding: 0 4px; - text-decoration: none; -} - -a.headerlink:hover { - color: #444; - background: #EAEAEA; -} - -div.body p, div.body dd, div.body li { - line-height: 1.4em; -} - -div.admonition { - margin: 20px 0px; - padding: 10px 30px; - background-color: #EEE; - border: 1px solid #CCC; -} - -div.admonition tt.xref, div.admonition code.xref, div.admonition a tt { - background-color: #FBFBFB; - border-bottom: 1px solid #fafafa; -} - -div.admonition p.admonition-title { - font-family: Georgia, serif; - font-weight: normal; - font-size: 24px; - margin: 0 0 10px 0; - padding: 0; - line-height: 1; -} - -div.admonition p.last { - margin-bottom: 0; -} - -dt:target, .highlight { - background: #FAF3E8; -} - -div.warning { - background-color: #FCC; - border: 1px solid #FAA; -} - -div.danger { - background-color: #FCC; - border: 1px solid #FAA; - -moz-box-shadow: 2px 2px 4px #D52C2C; - -webkit-box-shadow: 2px 2px 4px #D52C2C; - box-shadow: 2px 2px 4px #D52C2C; -} - -div.error { - background-color: #FCC; - border: 1px solid #FAA; - -moz-box-shadow: 2px 2px 4px #D52C2C; - -webkit-box-shadow: 2px 2px 4px #D52C2C; - box-shadow: 2px 2px 4px #D52C2C; -} - -div.caution { - background-color: #FCC; - border: 1px solid #FAA; -} - -div.attention { - background-color: #FCC; - border: 1px solid #FAA; -} - -div.important { - background-color: #EEE; - border: 1px solid #CCC; -} - -div.note { - background-color: #EEE; - border: 1px solid #CCC; -} - -div.tip { - background-color: #EEE; - border: 1px solid #CCC; -} - -div.hint { - background-color: #EEE; - border: 1px solid #CCC; -} - -div.seealso { - background-color: #EEE; - border: 1px solid #CCC; -} - -div.topic { - background-color: #EEE; -} - -p.admonition-title { - display: inline; -} - -p.admonition-title:after { - content: ":"; -} - -pre, tt, code { - font-family: 'Consolas', 'Menlo', 'DejaVu Sans Mono', 'Bitstream Vera Sans Mono', monospace; - font-size: 0.9em; -} - -.hll { - background-color: #FFC; - margin: 0 -12px; - padding: 0 12px; - display: block; -} - -img.screenshot { -} - -tt.descname, tt.descclassname, code.descname, code.descclassname { - font-size: 0.95em; -} - -tt.descname, code.descname { - padding-right: 0.08em; -} - -img.screenshot { - -moz-box-shadow: 2px 2px 4px #EEE; - -webkit-box-shadow: 2px 2px 4px #EEE; - box-shadow: 2px 2px 4px #EEE; -} - -table.docutils { - border: 1px solid #888; - -moz-box-shadow: 2px 2px 4px #EEE; - -webkit-box-shadow: 2px 2px 4px #EEE; - box-shadow: 2px 2px 4px #EEE; -} - -table.docutils td, table.docutils th { - border: 1px solid #888; - padding: 0.25em 0.7em; -} - -table.field-list, table.footnote { - border: none; - -moz-box-shadow: none; - -webkit-box-shadow: none; - box-shadow: none; -} - -table.footnote { - margin: 15px 0; - width: 100%; - border: 1px solid #EEE; - background: #FDFDFD; - font-size: 0.9em; -} - -table.footnote + table.footnote { - margin-top: -15px; - border-top: none; -} - -table.field-list th { - padding: 0 0.8em 0 0; -} - -table.field-list td { - padding: 0; -} - -table.field-list p { - margin-bottom: 0.8em; -} - -/* Cloned from - * https://github.com/sphinx-doc/sphinx/commit/ef60dbfce09286b20b7385333d63a60321784e68 - */ -.field-name { - -moz-hyphens: manual; - -ms-hyphens: manual; - -webkit-hyphens: manual; - hyphens: manual; -} - -table.footnote td.label { - width: .1px; - padding: 0.3em 0 0.3em 0.5em; -} - -table.footnote td { - padding: 0.3em 0.5em; -} - -dl { - margin-left: 0; - margin-right: 0; - margin-top: 0; - padding: 0; -} - -dl dd { - margin-left: 30px; -} - -blockquote { - margin: 0 0 0 30px; - padding: 0; -} - -ul, ol { - /* Matches the 30px from the narrow-screen "li > ul" selector below */ - margin: 10px 0 10px 30px; - padding: 0; -} - -pre { - background: unset; - padding: 7px 30px; - margin: 15px 0px; - line-height: 1.3em; -} - -div.viewcode-block:target { - background: #ffd; -} - -dl pre, blockquote pre, li pre { - margin-left: 0; - padding-left: 30px; -} - -tt, code { - background-color: #ecf0f3; - color: #222; - /* padding: 1px 2px; */ -} - -tt.xref, code.xref, a tt { - background-color: #FBFBFB; - border-bottom: 1px solid #fff; -} - -a.reference { - text-decoration: none; - border-bottom: 1px dotted #004B6B; -} - -a.reference:hover { - border-bottom: 1px solid #6D4100; -} - -/* Don't put an underline on images */ -a.image-reference, a.image-reference:hover { - border-bottom: none; -} - -a.footnote-reference { - text-decoration: none; - font-size: 0.7em; - vertical-align: top; - border-bottom: 1px dotted #004B6B; -} - -a.footnote-reference:hover { - border-bottom: 1px solid #6D4100; -} - -a:hover tt, a:hover code { - background: #EEE; -} -div.sphinxsidebar { - position: fixed; - margin-left: 0; -} - -@media screen and (max-width: 1000px) { - - body { - margin: 0; - padding: 20px 30px; - } - - div.documentwrapper { - float: none; - background: #fff; - margin-left: 0; - margin-top: 0; - margin-right: 0; - margin-bottom: 0; - } - - div.sphinxsidebar { - display: block; - float: none; - width: unset; - margin: -20px -30px 20px -30px; - position: static; - padding: 10px 20px; - background: #333; - color: #FFF; - } - - div.sphinxsidebar h3, div.sphinxsidebar h4, div.sphinxsidebar p, - div.sphinxsidebar h3 a { - color: #fff; - } - - div.sphinxsidebar a { - color: #AAA; - } - - div.sphinxsidebar p.logo { - display: none; - } - - div.document { - width: 100%; - margin: 0; - } - - div.footer { - display: none; - } - - div.bodywrapper { - margin: 0; - } - - div.body { - min-height: 0; - min-width: auto; /* fixes width on small screens, breaks .hll */ - padding: 0; - } - - .hll { - /* "fixes" the breakage */ - width: max-content; - } - - .rtd_doc_footer { - display: none; - } - - .document { - width: auto; - } - - .footer { - width: auto; - } - - .github { - display: none; - } - - ul { - margin-left: 0; - } - - li > ul { - /* Matches the 30px from the "ul, ol" selector above */ - margin-left: 30px; - } -} - - -/* misc. */ - -.revsys-inline { - display: none!important; -} - -/* Hide ugly table cell borders in ..bibliography:: directive output */ -table.docutils.citation, table.docutils.citation td, table.docutils.citation th { - border: none; - /* Below needed in some edge cases; if not applied, bottom shadows appear */ - -moz-box-shadow: none; - -webkit-box-shadow: none; - box-shadow: none; -} - - -/* relbar */ - -.related { - line-height: 30px; - width: 100%; - font-size: 0.9rem; -} - -.related.top { - border-bottom: 1px solid #EEE; - margin-bottom: 20px; -} - -.related.bottom { - border-top: 1px solid #EEE; -} - -.related ul { - padding: 0; - margin: 0; - list-style: none; -} - -.related li { - display: inline; -} - -nav#rellinks { - float: right; -} - -nav#rellinks li+li:before { - content: "|"; -} - -nav#breadcrumbs li+li:before { - content: "\00BB"; -} - -/* Hide certain items when printing */ -@media print { - div.related { - display: none; - } -} - -img.github { - position: absolute; - top: 0; - border: 0; - right: 0; -} \ No newline at end of file diff --git a/docs/build/html/_static/basic.css b/docs/build/html/_static/basic.css deleted file mode 100644 index d9846da..0000000 --- a/docs/build/html/_static/basic.css +++ /dev/null @@ -1,914 +0,0 @@ -/* - * Sphinx stylesheet -- basic theme. - */ - -/* -- main layout ----------------------------------------------------------- */ - -div.clearer { - clear: both; -} - -div.section::after { - display: block; - content: ''; - clear: left; -} - -/* -- relbar ---------------------------------------------------------------- */ - -div.related { - width: 100%; - font-size: 90%; -} - -div.related h3 { - display: none; -} - -div.related ul { - margin: 0; - padding: 0 0 0 10px; - list-style: none; -} - -div.related li { - display: inline; -} - -div.related li.right { - float: right; - margin-right: 5px; -} - -/* -- sidebar --------------------------------------------------------------- */ - -div.sphinxsidebarwrapper { - padding: 10px 5px 0 10px; -} - -div.sphinxsidebar { - float: left; - width: 230px; - margin-left: -100%; - font-size: 90%; - word-wrap: break-word; - overflow-wrap : break-word; -} - -div.sphinxsidebar ul { - list-style: none; -} - -div.sphinxsidebar ul ul, -div.sphinxsidebar ul.want-points { - margin-left: 20px; - list-style: square; -} - -div.sphinxsidebar ul ul { - margin-top: 0; - margin-bottom: 0; -} - -div.sphinxsidebar form { - margin-top: 10px; -} - -div.sphinxsidebar input { - border: 1px solid #98dbcc; - font-family: sans-serif; - font-size: 1em; -} - -div.sphinxsidebar #searchbox form.search { - overflow: hidden; -} - -div.sphinxsidebar #searchbox input[type="text"] { - float: left; - width: 80%; - padding: 0.25em; - box-sizing: border-box; -} - -div.sphinxsidebar #searchbox input[type="submit"] { - float: left; - width: 20%; - border-left: none; - padding: 0.25em; - box-sizing: border-box; -} - - -img { - border: 0; - max-width: 100%; -} - -/* -- search page ----------------------------------------------------------- */ - -ul.search { - margin-top: 10px; -} - -ul.search li { - padding: 5px 0; -} - -ul.search li a { - font-weight: bold; -} - -ul.search li p.context { - color: #888; - margin: 2px 0 0 30px; - text-align: left; -} - -ul.keywordmatches li.goodmatch a { - font-weight: bold; -} - -/* -- index page ------------------------------------------------------------ */ - -table.contentstable { - width: 90%; - margin-left: auto; - margin-right: auto; -} - -table.contentstable p.biglink { - line-height: 150%; -} - -a.biglink { - font-size: 1.3em; -} - -span.linkdescr { - font-style: italic; - padding-top: 5px; - font-size: 90%; -} - -/* -- general index --------------------------------------------------------- */ - -table.indextable { - width: 100%; -} - -table.indextable td { - text-align: left; - vertical-align: top; -} - -table.indextable ul { - margin-top: 0; - margin-bottom: 0; - list-style-type: none; -} - -table.indextable > tbody > tr > td > ul { - padding-left: 0em; -} - -table.indextable tr.pcap { - height: 10px; -} - -table.indextable tr.cap { - margin-top: 10px; - background-color: #f2f2f2; -} - -img.toggler { - margin-right: 3px; - margin-top: 3px; - cursor: pointer; -} - -div.modindex-jumpbox { - border-top: 1px solid #ddd; - border-bottom: 1px solid #ddd; - margin: 1em 0 1em 0; - padding: 0.4em; -} - -div.genindex-jumpbox { - border-top: 1px solid #ddd; - border-bottom: 1px solid #ddd; - margin: 1em 0 1em 0; - padding: 0.4em; -} - -/* -- domain module index --------------------------------------------------- */ - -table.modindextable td { - padding: 2px; - border-collapse: collapse; -} - -/* -- general body styles --------------------------------------------------- */ - -div.body { - min-width: inherit; - max-width: 800px; -} - -div.body p, div.body dd, div.body li, div.body blockquote { - -moz-hyphens: auto; - -ms-hyphens: auto; - -webkit-hyphens: auto; - hyphens: auto; -} - -a.headerlink { - visibility: hidden; -} - -a:visited { - color: #551A8B; -} - -h1:hover > a.headerlink, -h2:hover > a.headerlink, -h3:hover > a.headerlink, -h4:hover > a.headerlink, -h5:hover > a.headerlink, -h6:hover > a.headerlink, -dt:hover > a.headerlink, -caption:hover > a.headerlink, -p.caption:hover > a.headerlink, -div.code-block-caption:hover > a.headerlink { - visibility: visible; -} - -div.body p.caption { - text-align: inherit; -} - -div.body td { - text-align: left; -} - -.first { - margin-top: 0 !important; -} - -p.rubric { - margin-top: 30px; - font-weight: bold; -} - -img.align-left, figure.align-left, .figure.align-left, object.align-left { - clear: left; - float: left; - margin-right: 1em; -} - -img.align-right, figure.align-right, .figure.align-right, object.align-right { - clear: right; - float: right; - margin-left: 1em; -} - -img.align-center, figure.align-center, .figure.align-center, object.align-center { - display: block; - margin-left: auto; - margin-right: auto; -} - -img.align-default, figure.align-default, .figure.align-default { - display: block; - margin-left: auto; - margin-right: auto; -} - -.align-left { - text-align: left; -} - -.align-center { - text-align: center; -} - -.align-default { - text-align: center; -} - -.align-right { - text-align: right; -} - -/* -- sidebars -------------------------------------------------------------- */ - -div.sidebar, -aside.sidebar { - margin: 0 0 0.5em 1em; - border: 1px solid #ddb; - padding: 7px; - background-color: #ffe; - width: 40%; - float: right; - clear: right; - overflow-x: auto; -} - -p.sidebar-title { - font-weight: bold; -} - -nav.contents, -aside.topic, -div.admonition, div.topic, blockquote { - clear: left; -} - -/* -- topics ---------------------------------------------------------------- */ - -nav.contents, -aside.topic, -div.topic { - border: 1px solid #ccc; - padding: 7px; - margin: 10px 0 10px 0; -} - -p.topic-title { - font-size: 1.1em; - font-weight: bold; - margin-top: 10px; -} - -/* -- admonitions ----------------------------------------------------------- */ - -div.admonition { - margin-top: 10px; - margin-bottom: 10px; - padding: 7px; -} - -div.admonition dt { - font-weight: bold; -} - -p.admonition-title { - margin: 0px 10px 5px 0px; - font-weight: bold; -} - -div.body p.centered { - text-align: center; - margin-top: 25px; -} - -/* -- content of sidebars/topics/admonitions -------------------------------- */ - -div.sidebar > :last-child, -aside.sidebar > :last-child, -nav.contents > :last-child, -aside.topic > :last-child, -div.topic > :last-child, -div.admonition > :last-child { - margin-bottom: 0; -} - -div.sidebar::after, -aside.sidebar::after, -nav.contents::after, -aside.topic::after, -div.topic::after, -div.admonition::after, -blockquote::after { - display: block; - content: ''; - clear: both; -} - -/* -- tables ---------------------------------------------------------------- */ - -table.docutils { - margin-top: 10px; - margin-bottom: 10px; - border: 0; - border-collapse: collapse; -} - -table.align-center { - margin-left: auto; - margin-right: auto; -} - -table.align-default { - margin-left: auto; - margin-right: auto; -} - -table caption span.caption-number { - font-style: italic; -} - -table caption span.caption-text { -} - -table.docutils td, table.docutils th { - padding: 1px 8px 1px 5px; - border-top: 0; - border-left: 0; - border-right: 0; - border-bottom: 1px solid #aaa; -} - -th { - text-align: left; - padding-right: 5px; -} - -table.citation { - border-left: solid 1px gray; - margin-left: 1px; -} - -table.citation td { - border-bottom: none; -} - -th > :first-child, -td > :first-child { - margin-top: 0px; -} - -th > :last-child, -td > :last-child { - margin-bottom: 0px; -} - -/* -- figures --------------------------------------------------------------- */ - -div.figure, figure { - margin: 0.5em; - padding: 0.5em; -} - -div.figure p.caption, figcaption { - padding: 0.3em; -} - -div.figure p.caption span.caption-number, -figcaption span.caption-number { - font-style: italic; -} - -div.figure p.caption span.caption-text, -figcaption span.caption-text { -} - -/* -- field list styles ----------------------------------------------------- */ - -table.field-list td, table.field-list th { - border: 0 !important; -} - -.field-list ul { - margin: 0; - padding-left: 1em; -} - -.field-list p { - margin: 0; -} - -.field-name { - -moz-hyphens: manual; - -ms-hyphens: manual; - -webkit-hyphens: manual; - hyphens: manual; -} - -/* -- hlist styles ---------------------------------------------------------- */ - -table.hlist { - margin: 1em 0; -} - -table.hlist td { - vertical-align: top; -} - -/* -- object description styles --------------------------------------------- */ - -.sig { - font-family: 'Consolas', 'Menlo', 'DejaVu Sans Mono', 'Bitstream Vera Sans Mono', monospace; -} - -.sig-name, code.descname { - background-color: transparent; - font-weight: bold; -} - -.sig-name { - font-size: 1.1em; -} - -code.descname { - font-size: 1.2em; -} - -.sig-prename, code.descclassname { - background-color: transparent; -} - -.optional { - font-size: 1.3em; -} - -.sig-paren { - font-size: larger; -} - -.sig-param.n { - font-style: italic; -} - -/* C++ specific styling */ - -.sig-inline.c-texpr, -.sig-inline.cpp-texpr { - font-family: unset; -} - -.sig.c .k, .sig.c .kt, -.sig.cpp .k, .sig.cpp .kt { - color: #0033B3; -} - -.sig.c .m, -.sig.cpp .m { - color: #1750EB; -} - -.sig.c .s, .sig.c .sc, -.sig.cpp .s, .sig.cpp .sc { - color: #067D17; -} - - -/* -- other body styles ----------------------------------------------------- */ - -ol.arabic { - list-style: decimal; -} - -ol.loweralpha { - list-style: lower-alpha; -} - -ol.upperalpha { - list-style: upper-alpha; -} - -ol.lowerroman { - list-style: lower-roman; -} - -ol.upperroman { - list-style: upper-roman; -} - -:not(li) > ol > li:first-child > :first-child, -:not(li) > ul > li:first-child > :first-child { - margin-top: 0px; -} - -:not(li) > ol > li:last-child > :last-child, -:not(li) > ul > li:last-child > :last-child { - margin-bottom: 0px; -} - -ol.simple ol p, -ol.simple ul p, -ul.simple ol p, -ul.simple ul p { - margin-top: 0; -} - -ol.simple > li:not(:first-child) > p, -ul.simple > li:not(:first-child) > p { - margin-top: 0; -} - -ol.simple p, -ul.simple p { - margin-bottom: 0; -} - -aside.footnote > span, -div.citation > span { - float: left; -} -aside.footnote > span:last-of-type, -div.citation > span:last-of-type { - padding-right: 0.5em; -} -aside.footnote > p { - margin-left: 2em; -} -div.citation > p { - margin-left: 4em; -} -aside.footnote > p:last-of-type, -div.citation > p:last-of-type { - margin-bottom: 0em; -} -aside.footnote > p:last-of-type:after, -div.citation > p:last-of-type:after { - content: ""; - clear: both; -} - -dl.field-list { - display: grid; - grid-template-columns: fit-content(30%) auto; -} - -dl.field-list > dt { - font-weight: bold; - word-break: break-word; - padding-left: 0.5em; - padding-right: 5px; -} - -dl.field-list > dd { - padding-left: 0.5em; - margin-top: 0em; - margin-left: 0em; - margin-bottom: 0em; -} - -dl { - margin-bottom: 15px; -} - -dd > :first-child { - margin-top: 0px; -} - -dd ul, dd table { - margin-bottom: 10px; -} - -dd { - margin-top: 3px; - margin-bottom: 10px; - margin-left: 30px; -} - -.sig dd { - margin-top: 0px; - margin-bottom: 0px; -} - -.sig dl { - margin-top: 0px; - margin-bottom: 0px; -} - -dl > dd:last-child, -dl > dd:last-child > :last-child { - margin-bottom: 0; -} - -dt:target, span.highlighted { - background-color: #fbe54e; -} - -rect.highlighted { - fill: #fbe54e; -} - -dl.glossary dt { - font-weight: bold; - font-size: 1.1em; -} - -.versionmodified { - font-style: italic; -} - -.system-message { - background-color: #fda; - padding: 5px; - border: 3px solid red; -} - -.footnote:target { - background-color: #ffa; -} - -.line-block { - display: block; - margin-top: 1em; - margin-bottom: 1em; -} - -.line-block .line-block { - margin-top: 0; - margin-bottom: 0; - margin-left: 1.5em; -} - -.guilabel, .menuselection { - font-family: sans-serif; -} - -.accelerator { - text-decoration: underline; -} - -.classifier { - font-style: oblique; -} - -.classifier:before { - font-style: normal; - margin: 0 0.5em; - content: ":"; - display: inline-block; -} - -abbr, acronym { - border-bottom: dotted 1px; - cursor: help; -} - -.translated { - background-color: rgba(207, 255, 207, 0.2) -} - -.untranslated { - background-color: rgba(255, 207, 207, 0.2) -} - -/* -- code displays --------------------------------------------------------- */ - -pre { - overflow: auto; - overflow-y: hidden; /* fixes display issues on Chrome browsers */ -} - -pre, div[class*="highlight-"] { - clear: both; -} - -span.pre { - -moz-hyphens: none; - -ms-hyphens: none; - -webkit-hyphens: none; - hyphens: none; - white-space: nowrap; -} - -div[class*="highlight-"] { - margin: 1em 0; -} - -td.linenos pre { - border: 0; - background-color: transparent; - color: #aaa; -} - -table.highlighttable { - display: block; -} - -table.highlighttable tbody { - display: block; -} - -table.highlighttable tr { - display: flex; -} - -table.highlighttable td { - margin: 0; - padding: 0; -} - -table.highlighttable td.linenos { - padding-right: 0.5em; -} - -table.highlighttable td.code { - flex: 1; - overflow: hidden; -} - -.highlight .hll { - display: block; -} - -div.highlight pre, -table.highlighttable pre { - margin: 0; -} - -div.code-block-caption + div { - margin-top: 0; -} - -div.code-block-caption { - margin-top: 1em; - padding: 2px 5px; - font-size: small; -} - -div.code-block-caption code { - background-color: transparent; -} - -table.highlighttable td.linenos, -span.linenos, -div.highlight span.gp { /* gp: Generic.Prompt */ - user-select: none; - -webkit-user-select: text; /* Safari fallback only */ - -webkit-user-select: none; /* Chrome/Safari */ - -moz-user-select: none; /* Firefox */ - -ms-user-select: none; /* IE10+ */ -} - -div.code-block-caption span.caption-number { - padding: 0.1em 0.3em; - font-style: italic; -} - -div.code-block-caption span.caption-text { -} - -div.literal-block-wrapper { - margin: 1em 0; -} - -code.xref, a code { - background-color: transparent; - font-weight: bold; -} - -h1 code, h2 code, h3 code, h4 code, h5 code, h6 code { - background-color: transparent; -} - -.viewcode-link { - float: right; -} - -.viewcode-back { - float: right; - font-family: sans-serif; -} - -div.viewcode-block:target { - margin: -1px -10px; - padding: 0 10px; -} - -/* -- math display ---------------------------------------------------------- */ - -img.math { - vertical-align: middle; -} - -div.body div.math p { - text-align: center; -} - -span.eqno { - float: right; -} - -span.eqno a.headerlink { - position: absolute; - z-index: 1; -} - -div.math:hover a.headerlink { - visibility: visible; -} - -/* -- printout stylesheet --------------------------------------------------- */ - -@media print { - div.document, - div.documentwrapper, - div.bodywrapper { - margin: 0 !important; - width: 100%; - } - - div.sphinxsidebar, - div.related, - div.footer, - #top-link { - display: none; - } -} \ No newline at end of file diff --git a/docs/build/html/_static/custom.css b/docs/build/html/_static/custom.css deleted file mode 100644 index 2a924f1..0000000 --- a/docs/build/html/_static/custom.css +++ /dev/null @@ -1 +0,0 @@ -/* This file intentionally left blank. */ diff --git a/docs/build/html/_static/doctools.js b/docs/build/html/_static/doctools.js deleted file mode 100644 index 0398ebb..0000000 --- a/docs/build/html/_static/doctools.js +++ /dev/null @@ -1,149 +0,0 @@ -/* - * Base JavaScript utilities for all Sphinx HTML documentation. - */ -"use strict"; - -const BLACKLISTED_KEY_CONTROL_ELEMENTS = new Set([ - "TEXTAREA", - "INPUT", - "SELECT", - "BUTTON", -]); - -const _ready = (callback) => { - if (document.readyState !== "loading") { - callback(); - } else { - document.addEventListener("DOMContentLoaded", callback); - } -}; - -/** - * Small JavaScript module for the documentation. - */ -const Documentation = { - init: () => { - Documentation.initDomainIndexTable(); - Documentation.initOnKeyListeners(); - }, - - /** - * i18n support - */ - TRANSLATIONS: {}, - PLURAL_EXPR: (n) => (n === 1 ? 0 : 1), - LOCALE: "unknown", - - // gettext and ngettext don't access this so that the functions - // can safely bound to a different name (_ = Documentation.gettext) - gettext: (string) => { - const translated = Documentation.TRANSLATIONS[string]; - switch (typeof translated) { - case "undefined": - return string; // no translation - case "string": - return translated; // translation exists - default: - return translated[0]; // (singular, plural) translation tuple exists - } - }, - - ngettext: (singular, plural, n) => { - const translated = Documentation.TRANSLATIONS[singular]; - if (typeof translated !== "undefined") - return translated[Documentation.PLURAL_EXPR(n)]; - return n === 1 ? singular : plural; - }, - - addTranslations: (catalog) => { - Object.assign(Documentation.TRANSLATIONS, catalog.messages); - Documentation.PLURAL_EXPR = new Function( - "n", - `return (${catalog.plural_expr})` - ); - Documentation.LOCALE = catalog.locale; - }, - - /** - * helper function to focus on search bar - */ - focusSearchBar: () => { - document.querySelectorAll("input[name=q]")[0]?.focus(); - }, - - /** - * Initialise the domain index toggle buttons - */ - initDomainIndexTable: () => { - const toggler = (el) => { - const idNumber = el.id.substr(7); - const toggledRows = document.querySelectorAll(`tr.cg-${idNumber}`); - if (el.src.substr(-9) === "minus.png") { - el.src = `${el.src.substr(0, el.src.length - 9)}plus.png`; - toggledRows.forEach((el) => (el.style.display = "none")); - } else { - el.src = `${el.src.substr(0, el.src.length - 8)}minus.png`; - toggledRows.forEach((el) => (el.style.display = "")); - } - }; - - const togglerElements = document.querySelectorAll("img.toggler"); - togglerElements.forEach((el) => - el.addEventListener("click", (event) => toggler(event.currentTarget)) - ); - togglerElements.forEach((el) => (el.style.display = "")); - if (DOCUMENTATION_OPTIONS.COLLAPSE_INDEX) togglerElements.forEach(toggler); - }, - - initOnKeyListeners: () => { - // only install a listener if it is really needed - if ( - !DOCUMENTATION_OPTIONS.NAVIGATION_WITH_KEYS && - !DOCUMENTATION_OPTIONS.ENABLE_SEARCH_SHORTCUTS - ) - return; - - document.addEventListener("keydown", (event) => { - // bail for input elements - if (BLACKLISTED_KEY_CONTROL_ELEMENTS.has(document.activeElement.tagName)) return; - // bail with special keys - if (event.altKey || event.ctrlKey || event.metaKey) return; - - if (!event.shiftKey) { - switch (event.key) { - case "ArrowLeft": - if (!DOCUMENTATION_OPTIONS.NAVIGATION_WITH_KEYS) break; - - const prevLink = document.querySelector('link[rel="prev"]'); - if (prevLink && prevLink.href) { - window.location.href = prevLink.href; - event.preventDefault(); - } - break; - case "ArrowRight": - if (!DOCUMENTATION_OPTIONS.NAVIGATION_WITH_KEYS) break; - - const nextLink = document.querySelector('link[rel="next"]'); - if (nextLink && nextLink.href) { - window.location.href = nextLink.href; - event.preventDefault(); - } - break; - } - } - - // some keyboard layouts may need Shift to get / - switch (event.key) { - case "/": - if (!DOCUMENTATION_OPTIONS.ENABLE_SEARCH_SHORTCUTS) break; - Documentation.focusSearchBar(); - event.preventDefault(); - } - }); - }, -}; - -// quick alias for translations -const _ = Documentation.gettext; - -_ready(Documentation.init); diff --git a/docs/build/html/_static/documentation_options.js b/docs/build/html/_static/documentation_options.js deleted file mode 100644 index 529239f..0000000 --- a/docs/build/html/_static/documentation_options.js +++ /dev/null @@ -1,13 +0,0 @@ -const DOCUMENTATION_OPTIONS = { - VERSION: '1.0', - LANGUAGE: 'en', - COLLAPSE_INDEX: false, - BUILDER: 'html', - FILE_SUFFIX: '.html', - LINK_SUFFIX: '.html', - HAS_SOURCE: true, - SOURCELINK_SUFFIX: '.txt', - NAVIGATION_WITH_KEYS: false, - SHOW_SEARCH_SUMMARY: true, - ENABLE_SEARCH_SHORTCUTS: true, -}; \ No newline at end of file diff --git a/docs/build/html/_static/file.png b/docs/build/html/_static/file.png deleted file mode 100644 index a858a41..0000000 Binary files a/docs/build/html/_static/file.png and /dev/null differ diff --git a/docs/build/html/_static/github-banner.svg b/docs/build/html/_static/github-banner.svg deleted file mode 100644 index c47d9dc..0000000 --- a/docs/build/html/_static/github-banner.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/docs/build/html/_static/language_data.js b/docs/build/html/_static/language_data.js deleted file mode 100644 index c7fe6c6..0000000 --- a/docs/build/html/_static/language_data.js +++ /dev/null @@ -1,192 +0,0 @@ -/* - * This script contains the language-specific data used by searchtools.js, - * namely the list of stopwords, stemmer, scorer and splitter. - */ - -var stopwords = ["a", "and", "are", "as", "at", "be", "but", "by", "for", "if", "in", "into", "is", "it", "near", "no", "not", "of", "on", "or", "such", "that", "the", "their", "then", "there", "these", "they", "this", "to", "was", "will", "with"]; - - -/* Non-minified version is copied as a separate JS file, if available */ - -/** - * Porter Stemmer - */ -var Stemmer = function() { - - var step2list = { - ational: 'ate', - tional: 'tion', - enci: 'ence', - anci: 'ance', - izer: 'ize', - bli: 'ble', - alli: 'al', - entli: 'ent', - eli: 'e', - ousli: 'ous', - ization: 'ize', - ation: 'ate', - ator: 'ate', - alism: 'al', - iveness: 'ive', - fulness: 'ful', - ousness: 'ous', - aliti: 'al', - iviti: 'ive', - biliti: 'ble', - logi: 'log' - }; - - var step3list = { - icate: 'ic', - ative: '', - alize: 'al', - iciti: 'ic', - ical: 'ic', - ful: '', - ness: '' - }; - - var c = "[^aeiou]"; // consonant - var v = "[aeiouy]"; // vowel - var C = c + "[^aeiouy]*"; // consonant sequence - var V = v + "[aeiou]*"; // vowel sequence - - var mgr0 = "^(" + C + ")?" + V + C; // [C]VC... is m>0 - var meq1 = "^(" + C + ")?" + V + C + "(" + V + ")?$"; // [C]VC[V] is m=1 - var mgr1 = "^(" + C + ")?" + V + C + V + C; // [C]VCVC... is m>1 - var s_v = "^(" + C + ")?" + v; // vowel in stem - - this.stemWord = function (w) { - var stem; - var suffix; - var firstch; - var origword = w; - - if (w.length < 3) - return w; - - var re; - var re2; - var re3; - var re4; - - firstch = w.substr(0,1); - if (firstch == "y") - w = firstch.toUpperCase() + w.substr(1); - - // Step 1a - re = /^(.+?)(ss|i)es$/; - re2 = /^(.+?)([^s])s$/; - - if (re.test(w)) - w = w.replace(re,"$1$2"); - else if (re2.test(w)) - w = w.replace(re2,"$1$2"); - - // Step 1b - re = /^(.+?)eed$/; - re2 = /^(.+?)(ed|ing)$/; - if (re.test(w)) { - var fp = re.exec(w); - re = new RegExp(mgr0); - if (re.test(fp[1])) { - re = /.$/; - w = w.replace(re,""); - } - } - else if (re2.test(w)) { - var fp = re2.exec(w); - stem = fp[1]; - re2 = new RegExp(s_v); - if (re2.test(stem)) { - w = stem; - re2 = /(at|bl|iz)$/; - re3 = new RegExp("([^aeiouylsz])\\1$"); - re4 = new RegExp("^" + C + v + "[^aeiouwxy]$"); - if (re2.test(w)) - w = w + "e"; - else if (re3.test(w)) { - re = /.$/; - w = w.replace(re,""); - } - else if (re4.test(w)) - w = w + "e"; - } - } - - // Step 1c - re = /^(.+?)y$/; - if (re.test(w)) { - var fp = re.exec(w); - stem = fp[1]; - re = new RegExp(s_v); - if (re.test(stem)) - w = stem + "i"; - } - - // Step 2 - re = /^(.+?)(ational|tional|enci|anci|izer|bli|alli|entli|eli|ousli|ization|ation|ator|alism|iveness|fulness|ousness|aliti|iviti|biliti|logi)$/; - if (re.test(w)) { - var fp = re.exec(w); - stem = fp[1]; - suffix = fp[2]; - re = new RegExp(mgr0); - if (re.test(stem)) - w = stem + step2list[suffix]; - } - - // Step 3 - re = /^(.+?)(icate|ative|alize|iciti|ical|ful|ness)$/; - if (re.test(w)) { - var fp = re.exec(w); - stem = fp[1]; - suffix = fp[2]; - re = new RegExp(mgr0); - if (re.test(stem)) - w = stem + step3list[suffix]; - } - - // Step 4 - re = /^(.+?)(al|ance|ence|er|ic|able|ible|ant|ement|ment|ent|ou|ism|ate|iti|ous|ive|ize)$/; - re2 = /^(.+?)(s|t)(ion)$/; - if (re.test(w)) { - var fp = re.exec(w); - stem = fp[1]; - re = new RegExp(mgr1); - if (re.test(stem)) - w = stem; - } - else if (re2.test(w)) { - var fp = re2.exec(w); - stem = fp[1] + fp[2]; - re2 = new RegExp(mgr1); - if (re2.test(stem)) - w = stem; - } - - // Step 5 - re = /^(.+?)e$/; - if (re.test(w)) { - var fp = re.exec(w); - stem = fp[1]; - re = new RegExp(mgr1); - re2 = new RegExp(meq1); - re3 = new RegExp("^" + C + v + "[^aeiouwxy]$"); - if (re.test(stem) || (re2.test(stem) && !(re3.test(stem)))) - w = stem; - } - re = /ll$/; - re2 = new RegExp(mgr1); - if (re.test(w) && re2.test(w)) { - re = /.$/; - w = w.replace(re,""); - } - - // and turn initial Y back to y - if (firstch == "y") - w = firstch.toLowerCase() + w.substr(1); - return w; - } -} - diff --git a/docs/build/html/_static/minus.png b/docs/build/html/_static/minus.png deleted file mode 100644 index d96755f..0000000 Binary files a/docs/build/html/_static/minus.png and /dev/null differ diff --git a/docs/build/html/_static/plus.png b/docs/build/html/_static/plus.png deleted file mode 100644 index 7107cec..0000000 Binary files a/docs/build/html/_static/plus.png and /dev/null differ diff --git a/docs/build/html/_static/pygments.css b/docs/build/html/_static/pygments.css deleted file mode 100644 index 04a4174..0000000 --- a/docs/build/html/_static/pygments.css +++ /dev/null @@ -1,84 +0,0 @@ -pre { line-height: 125%; } -td.linenos .normal { color: inherit; background-color: transparent; padding-left: 5px; padding-right: 5px; } -span.linenos { color: inherit; background-color: transparent; padding-left: 5px; padding-right: 5px; } -td.linenos .special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; } -span.linenos.special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; } -.highlight .hll { background-color: #ffffcc } -.highlight { background: #f8f8f8; } -.highlight .c { color: #8f5902; font-style: italic } /* Comment */ -.highlight .err { color: #a40000; border: 1px solid #ef2929 } /* Error */ -.highlight .g { color: #000000 } /* Generic */ -.highlight .k { color: #004461; font-weight: bold } /* Keyword */ -.highlight .l { color: #000000 } /* Literal */ -.highlight .n { color: #000000 } /* Name */ -.highlight .o { color: #582800 } /* Operator */ -.highlight .x { color: #000000 } /* Other */ -.highlight .p { color: #000000; font-weight: bold } /* Punctuation */ -.highlight .ch { color: #8f5902; font-style: italic } /* Comment.Hashbang */ -.highlight .cm { color: #8f5902; font-style: italic } /* Comment.Multiline */ -.highlight .cp { color: #8f5902 } /* Comment.Preproc */ -.highlight .cpf { color: #8f5902; font-style: italic } /* Comment.PreprocFile */ -.highlight .c1 { color: #8f5902; font-style: italic } /* Comment.Single */ -.highlight .cs { color: #8f5902; font-style: italic } /* Comment.Special */ -.highlight .gd { color: #a40000 } /* Generic.Deleted */ -.highlight .ge { color: #000000; font-style: italic } /* Generic.Emph */ -.highlight .ges { color: #000000 } /* Generic.EmphStrong */ -.highlight .gr { color: #ef2929 } /* Generic.Error */ -.highlight .gh { color: #000080; font-weight: bold } /* Generic.Heading */ -.highlight .gi { color: #00A000 } /* Generic.Inserted */ -.highlight .go { color: #888888 } /* Generic.Output */ -.highlight .gp { color: #745334 } /* Generic.Prompt */ -.highlight .gs { color: #000000; font-weight: bold } /* Generic.Strong */ -.highlight .gu { color: #800080; font-weight: bold } /* Generic.Subheading */ -.highlight .gt { color: #a40000; font-weight: bold } /* Generic.Traceback */ -.highlight .kc { color: #004461; font-weight: bold } /* Keyword.Constant */ -.highlight .kd { color: #004461; font-weight: bold } /* Keyword.Declaration */ -.highlight .kn { color: #004461; font-weight: bold } /* Keyword.Namespace */ -.highlight .kp { color: #004461; font-weight: bold } /* Keyword.Pseudo */ -.highlight .kr { color: #004461; font-weight: bold } /* Keyword.Reserved */ -.highlight .kt { color: #004461; font-weight: bold } /* Keyword.Type */ -.highlight .ld { color: #000000 } /* Literal.Date */ -.highlight .m { color: #990000 } /* Literal.Number */ -.highlight .s { color: #4e9a06 } /* Literal.String */ -.highlight .na { color: #c4a000 } /* Name.Attribute */ -.highlight .nb { color: #004461 } /* Name.Builtin */ -.highlight .nc { color: #000000 } /* Name.Class */ -.highlight .no { color: #000000 } /* Name.Constant */ -.highlight .nd { color: #888888 } /* Name.Decorator */ -.highlight .ni { color: #ce5c00 } /* Name.Entity */ -.highlight .ne { color: #cc0000; font-weight: bold } /* Name.Exception */ -.highlight .nf { color: #000000 } /* Name.Function */ -.highlight .nl { color: #f57900 } /* Name.Label */ -.highlight .nn { color: #000000 } /* Name.Namespace */ -.highlight .nx { color: #000000 } /* Name.Other */ -.highlight .py { color: #000000 } /* Name.Property */ -.highlight .nt { color: #004461; font-weight: bold } /* Name.Tag */ -.highlight .nv { color: #000000 } /* Name.Variable */ -.highlight .ow { color: #004461; font-weight: bold } /* Operator.Word */ -.highlight .pm { color: #000000; font-weight: bold } /* Punctuation.Marker */ -.highlight .w { color: #f8f8f8 } /* Text.Whitespace */ -.highlight .mb { color: #990000 } /* Literal.Number.Bin */ -.highlight .mf { color: #990000 } /* Literal.Number.Float */ -.highlight .mh { color: #990000 } /* Literal.Number.Hex */ -.highlight .mi { color: #990000 } /* Literal.Number.Integer */ -.highlight .mo { color: #990000 } /* Literal.Number.Oct */ -.highlight .sa { color: #4e9a06 } /* Literal.String.Affix */ -.highlight .sb { color: #4e9a06 } /* Literal.String.Backtick */ -.highlight .sc { color: #4e9a06 } /* Literal.String.Char */ -.highlight .dl { color: #4e9a06 } /* Literal.String.Delimiter */ -.highlight .sd { color: #8f5902; font-style: italic } /* Literal.String.Doc */ -.highlight .s2 { color: #4e9a06 } /* Literal.String.Double */ -.highlight .se { color: #4e9a06 } /* Literal.String.Escape */ -.highlight .sh { color: #4e9a06 } /* Literal.String.Heredoc */ -.highlight .si { color: #4e9a06 } /* Literal.String.Interpol */ -.highlight .sx { color: #4e9a06 } /* Literal.String.Other */ -.highlight .sr { color: #4e9a06 } /* Literal.String.Regex */ -.highlight .s1 { color: #4e9a06 } /* Literal.String.Single */ -.highlight .ss { color: #4e9a06 } /* Literal.String.Symbol */ -.highlight .bp { color: #3465a4 } /* Name.Builtin.Pseudo */ -.highlight .fm { color: #000000 } /* Name.Function.Magic */ -.highlight .vc { color: #000000 } /* Name.Variable.Class */ -.highlight .vg { color: #000000 } /* Name.Variable.Global */ -.highlight .vi { color: #000000 } /* Name.Variable.Instance */ -.highlight .vm { color: #000000 } /* Name.Variable.Magic */ -.highlight .il { color: #990000 } /* Literal.Number.Integer.Long */ \ No newline at end of file diff --git a/docs/build/html/_static/searchtools.js b/docs/build/html/_static/searchtools.js deleted file mode 100644 index 2c774d1..0000000 --- a/docs/build/html/_static/searchtools.js +++ /dev/null @@ -1,632 +0,0 @@ -/* - * Sphinx JavaScript utilities for the full-text search. - */ -"use strict"; - -/** - * Simple result scoring code. - */ -if (typeof Scorer === "undefined") { - var Scorer = { - // Implement the following function to further tweak the score for each result - // The function takes a result array [docname, title, anchor, descr, score, filename] - // and returns the new score. - /* - score: result => { - const [docname, title, anchor, descr, score, filename, kind] = result - return score - }, - */ - - // query matches the full name of an object - objNameMatch: 11, - // or matches in the last dotted part of the object name - objPartialMatch: 6, - // Additive scores depending on the priority of the object - objPrio: { - 0: 15, // used to be importantResults - 1: 5, // used to be objectResults - 2: -5, // used to be unimportantResults - }, - // Used when the priority is not in the mapping. - objPrioDefault: 0, - - // query found in title - title: 15, - partialTitle: 7, - // query found in terms - term: 5, - partialTerm: 2, - }; -} - -// Global search result kind enum, used by themes to style search results. -class SearchResultKind { - static get index() { return "index"; } - static get object() { return "object"; } - static get text() { return "text"; } - static get title() { return "title"; } -} - -const _removeChildren = (element) => { - while (element && element.lastChild) element.removeChild(element.lastChild); -}; - -/** - * See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#escaping - */ -const _escapeRegExp = (string) => - string.replace(/[.*+\-?^${}()|[\]\\]/g, "\\$&"); // $& means the whole matched string - -const _displayItem = (item, searchTerms, highlightTerms) => { - const docBuilder = DOCUMENTATION_OPTIONS.BUILDER; - const docFileSuffix = DOCUMENTATION_OPTIONS.FILE_SUFFIX; - const docLinkSuffix = DOCUMENTATION_OPTIONS.LINK_SUFFIX; - const showSearchSummary = DOCUMENTATION_OPTIONS.SHOW_SEARCH_SUMMARY; - const contentRoot = document.documentElement.dataset.content_root; - - const [docName, title, anchor, descr, score, _filename, kind] = item; - - let listItem = document.createElement("li"); - // Add a class representing the item's type: - // can be used by a theme's CSS selector for styling - // See SearchResultKind for the class names. - listItem.classList.add(`kind-${kind}`); - let requestUrl; - let linkUrl; - if (docBuilder === "dirhtml") { - // dirhtml builder - let dirname = docName + "/"; - if (dirname.match(/\/index\/$/)) - dirname = dirname.substring(0, dirname.length - 6); - else if (dirname === "index/") dirname = ""; - requestUrl = contentRoot + dirname; - linkUrl = requestUrl; - } else { - // normal html builders - requestUrl = contentRoot + docName + docFileSuffix; - linkUrl = docName + docLinkSuffix; - } - let linkEl = listItem.appendChild(document.createElement("a")); - linkEl.href = linkUrl + anchor; - linkEl.dataset.score = score; - linkEl.innerHTML = title; - if (descr) { - listItem.appendChild(document.createElement("span")).innerHTML = - " (" + descr + ")"; - // highlight search terms in the description - if (SPHINX_HIGHLIGHT_ENABLED) // set in sphinx_highlight.js - highlightTerms.forEach((term) => _highlightText(listItem, term, "highlighted")); - } - else if (showSearchSummary) - fetch(requestUrl) - .then((responseData) => responseData.text()) - .then((data) => { - if (data) - listItem.appendChild( - Search.makeSearchSummary(data, searchTerms, anchor) - ); - // highlight search terms in the summary - if (SPHINX_HIGHLIGHT_ENABLED) // set in sphinx_highlight.js - highlightTerms.forEach((term) => _highlightText(listItem, term, "highlighted")); - }); - Search.output.appendChild(listItem); -}; -const _finishSearch = (resultCount) => { - Search.stopPulse(); - Search.title.innerText = _("Search Results"); - if (!resultCount) - Search.status.innerText = Documentation.gettext( - "Your search did not match any documents. Please make sure that all words are spelled correctly and that you've selected enough categories." - ); - else - Search.status.innerText = Documentation.ngettext( - "Search finished, found one page matching the search query.", - "Search finished, found ${resultCount} pages matching the search query.", - resultCount, - ).replace('${resultCount}', resultCount); -}; -const _displayNextItem = ( - results, - resultCount, - searchTerms, - highlightTerms, -) => { - // results left, load the summary and display it - // this is intended to be dynamic (don't sub resultsCount) - if (results.length) { - _displayItem(results.pop(), searchTerms, highlightTerms); - setTimeout( - () => _displayNextItem(results, resultCount, searchTerms, highlightTerms), - 5 - ); - } - // search finished, update title and status message - else _finishSearch(resultCount); -}; -// Helper function used by query() to order search results. -// Each input is an array of [docname, title, anchor, descr, score, filename, kind]. -// Order the results by score (in opposite order of appearance, since the -// `_displayNextItem` function uses pop() to retrieve items) and then alphabetically. -const _orderResultsByScoreThenName = (a, b) => { - const leftScore = a[4]; - const rightScore = b[4]; - if (leftScore === rightScore) { - // same score: sort alphabetically - const leftTitle = a[1].toLowerCase(); - const rightTitle = b[1].toLowerCase(); - if (leftTitle === rightTitle) return 0; - return leftTitle > rightTitle ? -1 : 1; // inverted is intentional - } - return leftScore > rightScore ? 1 : -1; -}; - -/** - * Default splitQuery function. Can be overridden in ``sphinx.search`` with a - * custom function per language. - * - * The regular expression works by splitting the string on consecutive characters - * that are not Unicode letters, numbers, underscores, or emoji characters. - * This is the same as ``\W+`` in Python, preserving the surrogate pair area. - */ -if (typeof splitQuery === "undefined") { - var splitQuery = (query) => query - .split(/[^\p{Letter}\p{Number}_\p{Emoji_Presentation}]+/gu) - .filter(term => term) // remove remaining empty strings -} - -/** - * Search Module - */ -const Search = { - _index: null, - _queued_query: null, - _pulse_status: -1, - - htmlToText: (htmlString, anchor) => { - const htmlElement = new DOMParser().parseFromString(htmlString, 'text/html'); - for (const removalQuery of [".headerlink", "script", "style"]) { - htmlElement.querySelectorAll(removalQuery).forEach((el) => { el.remove() }); - } - if (anchor) { - const anchorContent = htmlElement.querySelector(`[role="main"] ${anchor}`); - if (anchorContent) return anchorContent.textContent; - - console.warn( - `Anchored content block not found. Sphinx search tries to obtain it via DOM query '[role=main] ${anchor}'. Check your theme or template.` - ); - } - - // if anchor not specified or not found, fall back to main content - const docContent = htmlElement.querySelector('[role="main"]'); - if (docContent) return docContent.textContent; - - console.warn( - "Content block not found. Sphinx search tries to obtain it via DOM query '[role=main]'. Check your theme or template." - ); - return ""; - }, - - init: () => { - const query = new URLSearchParams(window.location.search).get("q"); - document - .querySelectorAll('input[name="q"]') - .forEach((el) => (el.value = query)); - if (query) Search.performSearch(query); - }, - - loadIndex: (url) => - (document.body.appendChild(document.createElement("script")).src = url), - - setIndex: (index) => { - Search._index = index; - if (Search._queued_query !== null) { - const query = Search._queued_query; - Search._queued_query = null; - Search.query(query); - } - }, - - hasIndex: () => Search._index !== null, - - deferQuery: (query) => (Search._queued_query = query), - - stopPulse: () => (Search._pulse_status = -1), - - startPulse: () => { - if (Search._pulse_status >= 0) return; - - const pulse = () => { - Search._pulse_status = (Search._pulse_status + 1) % 4; - Search.dots.innerText = ".".repeat(Search._pulse_status); - if (Search._pulse_status >= 0) window.setTimeout(pulse, 500); - }; - pulse(); - }, - - /** - * perform a search for something (or wait until index is loaded) - */ - performSearch: (query) => { - // create the required interface elements - const searchText = document.createElement("h2"); - searchText.textContent = _("Searching"); - const searchSummary = document.createElement("p"); - searchSummary.classList.add("search-summary"); - searchSummary.innerText = ""; - const searchList = document.createElement("ul"); - searchList.setAttribute("role", "list"); - searchList.classList.add("search"); - - const out = document.getElementById("search-results"); - Search.title = out.appendChild(searchText); - Search.dots = Search.title.appendChild(document.createElement("span")); - Search.status = out.appendChild(searchSummary); - Search.output = out.appendChild(searchList); - - const searchProgress = document.getElementById("search-progress"); - // Some themes don't use the search progress node - if (searchProgress) { - searchProgress.innerText = _("Preparing search..."); - } - Search.startPulse(); - - // index already loaded, the browser was quick! - if (Search.hasIndex()) Search.query(query); - else Search.deferQuery(query); - }, - - _parseQuery: (query) => { - // stem the search terms and add them to the correct list - const stemmer = new Stemmer(); - const searchTerms = new Set(); - const excludedTerms = new Set(); - const highlightTerms = new Set(); - const objectTerms = new Set(splitQuery(query.toLowerCase().trim())); - splitQuery(query.trim()).forEach((queryTerm) => { - const queryTermLower = queryTerm.toLowerCase(); - - // maybe skip this "word" - // stopwords array is from language_data.js - if ( - stopwords.indexOf(queryTermLower) !== -1 || - queryTerm.match(/^\d+$/) - ) - return; - - // stem the word - let word = stemmer.stemWord(queryTermLower); - // select the correct list - if (word[0] === "-") excludedTerms.add(word.substr(1)); - else { - searchTerms.add(word); - highlightTerms.add(queryTermLower); - } - }); - - if (SPHINX_HIGHLIGHT_ENABLED) { // set in sphinx_highlight.js - localStorage.setItem("sphinx_highlight_terms", [...highlightTerms].join(" ")) - } - - // console.debug("SEARCH: searching for:"); - // console.info("required: ", [...searchTerms]); - // console.info("excluded: ", [...excludedTerms]); - - return [query, searchTerms, excludedTerms, highlightTerms, objectTerms]; - }, - - /** - * execute search (requires search index to be loaded) - */ - _performSearch: (query, searchTerms, excludedTerms, highlightTerms, objectTerms) => { - const filenames = Search._index.filenames; - const docNames = Search._index.docnames; - const titles = Search._index.titles; - const allTitles = Search._index.alltitles; - const indexEntries = Search._index.indexentries; - - // Collect multiple result groups to be sorted separately and then ordered. - // Each is an array of [docname, title, anchor, descr, score, filename, kind]. - const normalResults = []; - const nonMainIndexResults = []; - - _removeChildren(document.getElementById("search-progress")); - - const queryLower = query.toLowerCase().trim(); - for (const [title, foundTitles] of Object.entries(allTitles)) { - if (title.toLowerCase().trim().includes(queryLower) && (queryLower.length >= title.length/2)) { - for (const [file, id] of foundTitles) { - const score = Math.round(Scorer.title * queryLower.length / title.length); - const boost = titles[file] === title ? 1 : 0; // add a boost for document titles - normalResults.push([ - docNames[file], - titles[file] !== title ? `${titles[file]} > ${title}` : title, - id !== null ? "#" + id : "", - null, - score + boost, - filenames[file], - SearchResultKind.title, - ]); - } - } - } - - // search for explicit entries in index directives - for (const [entry, foundEntries] of Object.entries(indexEntries)) { - if (entry.includes(queryLower) && (queryLower.length >= entry.length/2)) { - for (const [file, id, isMain] of foundEntries) { - const score = Math.round(100 * queryLower.length / entry.length); - const result = [ - docNames[file], - titles[file], - id ? "#" + id : "", - null, - score, - filenames[file], - SearchResultKind.index, - ]; - if (isMain) { - normalResults.push(result); - } else { - nonMainIndexResults.push(result); - } - } - } - } - - // lookup as object - objectTerms.forEach((term) => - normalResults.push(...Search.performObjectSearch(term, objectTerms)) - ); - - // lookup as search terms in fulltext - normalResults.push(...Search.performTermsSearch(searchTerms, excludedTerms)); - - // let the scorer override scores with a custom scoring function - if (Scorer.score) { - normalResults.forEach((item) => (item[4] = Scorer.score(item))); - nonMainIndexResults.forEach((item) => (item[4] = Scorer.score(item))); - } - - // Sort each group of results by score and then alphabetically by name. - normalResults.sort(_orderResultsByScoreThenName); - nonMainIndexResults.sort(_orderResultsByScoreThenName); - - // Combine the result groups in (reverse) order. - // Non-main index entries are typically arbitrary cross-references, - // so display them after other results. - let results = [...nonMainIndexResults, ...normalResults]; - - // remove duplicate search results - // note the reversing of results, so that in the case of duplicates, the highest-scoring entry is kept - let seen = new Set(); - results = results.reverse().reduce((acc, result) => { - let resultStr = result.slice(0, 4).concat([result[5]]).map(v => String(v)).join(','); - if (!seen.has(resultStr)) { - acc.push(result); - seen.add(resultStr); - } - return acc; - }, []); - - return results.reverse(); - }, - - query: (query) => { - const [searchQuery, searchTerms, excludedTerms, highlightTerms, objectTerms] = Search._parseQuery(query); - const results = Search._performSearch(searchQuery, searchTerms, excludedTerms, highlightTerms, objectTerms); - - // for debugging - //Search.lastresults = results.slice(); // a copy - // console.info("search results:", Search.lastresults); - - // print the results - _displayNextItem(results, results.length, searchTerms, highlightTerms); - }, - - /** - * search for object names - */ - performObjectSearch: (object, objectTerms) => { - const filenames = Search._index.filenames; - const docNames = Search._index.docnames; - const objects = Search._index.objects; - const objNames = Search._index.objnames; - const titles = Search._index.titles; - - const results = []; - - const objectSearchCallback = (prefix, match) => { - const name = match[4] - const fullname = (prefix ? prefix + "." : "") + name; - const fullnameLower = fullname.toLowerCase(); - if (fullnameLower.indexOf(object) < 0) return; - - let score = 0; - const parts = fullnameLower.split("."); - - // check for different match types: exact matches of full name or - // "last name" (i.e. last dotted part) - if (fullnameLower === object || parts.slice(-1)[0] === object) - score += Scorer.objNameMatch; - else if (parts.slice(-1)[0].indexOf(object) > -1) - score += Scorer.objPartialMatch; // matches in last name - - const objName = objNames[match[1]][2]; - const title = titles[match[0]]; - - // If more than one term searched for, we require other words to be - // found in the name/title/description - const otherTerms = new Set(objectTerms); - otherTerms.delete(object); - if (otherTerms.size > 0) { - const haystack = `${prefix} ${name} ${objName} ${title}`.toLowerCase(); - if ( - [...otherTerms].some((otherTerm) => haystack.indexOf(otherTerm) < 0) - ) - return; - } - - let anchor = match[3]; - if (anchor === "") anchor = fullname; - else if (anchor === "-") anchor = objNames[match[1]][1] + "-" + fullname; - - const descr = objName + _(", in ") + title; - - // add custom score for some objects according to scorer - if (Scorer.objPrio.hasOwnProperty(match[2])) - score += Scorer.objPrio[match[2]]; - else score += Scorer.objPrioDefault; - - results.push([ - docNames[match[0]], - fullname, - "#" + anchor, - descr, - score, - filenames[match[0]], - SearchResultKind.object, - ]); - }; - Object.keys(objects).forEach((prefix) => - objects[prefix].forEach((array) => - objectSearchCallback(prefix, array) - ) - ); - return results; - }, - - /** - * search for full-text terms in the index - */ - performTermsSearch: (searchTerms, excludedTerms) => { - // prepare search - const terms = Search._index.terms; - const titleTerms = Search._index.titleterms; - const filenames = Search._index.filenames; - const docNames = Search._index.docnames; - const titles = Search._index.titles; - - const scoreMap = new Map(); - const fileMap = new Map(); - - // perform the search on the required terms - searchTerms.forEach((word) => { - const files = []; - const arr = [ - { files: terms[word], score: Scorer.term }, - { files: titleTerms[word], score: Scorer.title }, - ]; - // add support for partial matches - if (word.length > 2) { - const escapedWord = _escapeRegExp(word); - if (!terms.hasOwnProperty(word)) { - Object.keys(terms).forEach((term) => { - if (term.match(escapedWord)) - arr.push({ files: terms[term], score: Scorer.partialTerm }); - }); - } - if (!titleTerms.hasOwnProperty(word)) { - Object.keys(titleTerms).forEach((term) => { - if (term.match(escapedWord)) - arr.push({ files: titleTerms[term], score: Scorer.partialTitle }); - }); - } - } - - // no match but word was a required one - if (arr.every((record) => record.files === undefined)) return; - - // found search word in contents - arr.forEach((record) => { - if (record.files === undefined) return; - - let recordFiles = record.files; - if (recordFiles.length === undefined) recordFiles = [recordFiles]; - files.push(...recordFiles); - - // set score for the word in each file - recordFiles.forEach((file) => { - if (!scoreMap.has(file)) scoreMap.set(file, {}); - scoreMap.get(file)[word] = record.score; - }); - }); - - // create the mapping - files.forEach((file) => { - if (!fileMap.has(file)) fileMap.set(file, [word]); - else if (fileMap.get(file).indexOf(word) === -1) fileMap.get(file).push(word); - }); - }); - - // now check if the files don't contain excluded terms - const results = []; - for (const [file, wordList] of fileMap) { - // check if all requirements are matched - - // as search terms with length < 3 are discarded - const filteredTermCount = [...searchTerms].filter( - (term) => term.length > 2 - ).length; - if ( - wordList.length !== searchTerms.size && - wordList.length !== filteredTermCount - ) - continue; - - // ensure that none of the excluded terms is in the search result - if ( - [...excludedTerms].some( - (term) => - terms[term] === file || - titleTerms[term] === file || - (terms[term] || []).includes(file) || - (titleTerms[term] || []).includes(file) - ) - ) - break; - - // select one (max) score for the file. - const score = Math.max(...wordList.map((w) => scoreMap.get(file)[w])); - // add result to the result list - results.push([ - docNames[file], - titles[file], - "", - null, - score, - filenames[file], - SearchResultKind.text, - ]); - } - return results; - }, - - /** - * helper function to return a node containing the - * search summary for a given text. keywords is a list - * of stemmed words. - */ - makeSearchSummary: (htmlText, keywords, anchor) => { - const text = Search.htmlToText(htmlText, anchor); - if (text === "") return null; - - const textLower = text.toLowerCase(); - const actualStartPosition = [...keywords] - .map((k) => textLower.indexOf(k.toLowerCase())) - .filter((i) => i > -1) - .slice(-1)[0]; - const startWithContext = Math.max(actualStartPosition - 120, 0); - - const top = startWithContext === 0 ? "" : "..."; - const tail = startWithContext + 240 < text.length ? "..." : ""; - - let summary = document.createElement("p"); - summary.classList.add("context"); - summary.textContent = top + text.substr(startWithContext, 240).trim() + tail; - - return summary; - }, -}; - -_ready(Search.init); diff --git a/docs/build/html/_static/sphinx_highlight.js b/docs/build/html/_static/sphinx_highlight.js deleted file mode 100644 index 8a96c69..0000000 --- a/docs/build/html/_static/sphinx_highlight.js +++ /dev/null @@ -1,154 +0,0 @@ -/* Highlighting utilities for Sphinx HTML documentation. */ -"use strict"; - -const SPHINX_HIGHLIGHT_ENABLED = true - -/** - * highlight a given string on a node by wrapping it in - * span elements with the given class name. - */ -const _highlight = (node, addItems, text, className) => { - if (node.nodeType === Node.TEXT_NODE) { - const val = node.nodeValue; - const parent = node.parentNode; - const pos = val.toLowerCase().indexOf(text); - if ( - pos >= 0 && - !parent.classList.contains(className) && - !parent.classList.contains("nohighlight") - ) { - let span; - - const closestNode = parent.closest("body, svg, foreignObject"); - const isInSVG = closestNode && closestNode.matches("svg"); - if (isInSVG) { - span = document.createElementNS("http://www.w3.org/2000/svg", "tspan"); - } else { - span = document.createElement("span"); - span.classList.add(className); - } - - span.appendChild(document.createTextNode(val.substr(pos, text.length))); - const rest = document.createTextNode(val.substr(pos + text.length)); - parent.insertBefore( - span, - parent.insertBefore( - rest, - node.nextSibling - ) - ); - node.nodeValue = val.substr(0, pos); - /* There may be more occurrences of search term in this node. So call this - * function recursively on the remaining fragment. - */ - _highlight(rest, addItems, text, className); - - if (isInSVG) { - const rect = document.createElementNS( - "http://www.w3.org/2000/svg", - "rect" - ); - const bbox = parent.getBBox(); - rect.x.baseVal.value = bbox.x; - rect.y.baseVal.value = bbox.y; - rect.width.baseVal.value = bbox.width; - rect.height.baseVal.value = bbox.height; - rect.setAttribute("class", className); - addItems.push({ parent: parent, target: rect }); - } - } - } else if (node.matches && !node.matches("button, select, textarea")) { - node.childNodes.forEach((el) => _highlight(el, addItems, text, className)); - } -}; -const _highlightText = (thisNode, text, className) => { - let addItems = []; - _highlight(thisNode, addItems, text, className); - addItems.forEach((obj) => - obj.parent.insertAdjacentElement("beforebegin", obj.target) - ); -}; - -/** - * Small JavaScript module for the documentation. - */ -const SphinxHighlight = { - - /** - * highlight the search words provided in localstorage in the text - */ - highlightSearchWords: () => { - if (!SPHINX_HIGHLIGHT_ENABLED) return; // bail if no highlight - - // get and clear terms from localstorage - const url = new URL(window.location); - const highlight = - localStorage.getItem("sphinx_highlight_terms") - || url.searchParams.get("highlight") - || ""; - localStorage.removeItem("sphinx_highlight_terms") - url.searchParams.delete("highlight"); - window.history.replaceState({}, "", url); - - // get individual terms from highlight string - const terms = highlight.toLowerCase().split(/\s+/).filter(x => x); - if (terms.length === 0) return; // nothing to do - - // There should never be more than one element matching "div.body" - const divBody = document.querySelectorAll("div.body"); - const body = divBody.length ? divBody[0] : document.querySelector("body"); - window.setTimeout(() => { - terms.forEach((term) => _highlightText(body, term, "highlighted")); - }, 10); - - const searchBox = document.getElementById("searchbox"); - if (searchBox === null) return; - searchBox.appendChild( - document - .createRange() - .createContextualFragment( - '" - ) - ); - }, - - /** - * helper function to hide the search marks again - */ - hideSearchWords: () => { - document - .querySelectorAll("#searchbox .highlight-link") - .forEach((el) => el.remove()); - document - .querySelectorAll("span.highlighted") - .forEach((el) => el.classList.remove("highlighted")); - localStorage.removeItem("sphinx_highlight_terms") - }, - - initEscapeListener: () => { - // only install a listener if it is really needed - if (!DOCUMENTATION_OPTIONS.ENABLE_SEARCH_SHORTCUTS) return; - - document.addEventListener("keydown", (event) => { - // bail for input elements - if (BLACKLISTED_KEY_CONTROL_ELEMENTS.has(document.activeElement.tagName)) return; - // bail with special keys - if (event.shiftKey || event.altKey || event.ctrlKey || event.metaKey) return; - if (DOCUMENTATION_OPTIONS.ENABLE_SEARCH_SHORTCUTS && (event.key === "Escape")) { - SphinxHighlight.hideSearchWords(); - event.preventDefault(); - } - }); - }, -}; - -_ready(() => { - /* Do not call highlightSearchWords() when we are on the search page. - * It will highlight words from the *previous* search query. - */ - if (typeof Search === "undefined") SphinxHighlight.highlightSearchWords(); - SphinxHighlight.initEscapeListener(); -}); diff --git a/docs/build/html/genindex.html b/docs/build/html/genindex.html deleted file mode 100644 index 4ac4e46..0000000 --- a/docs/build/html/genindex.html +++ /dev/null @@ -1,140 +0,0 @@ - - - - - - - Index — Predspot 1.0 documentation - - - - - - - - - - - - - - - - - -
- - -
-
- - - -
- - -

Index

- -
- -
- - -
- - -
-
-
-
- - - - - - - \ No newline at end of file diff --git a/docs/build/html/index.html b/docs/build/html/index.html deleted file mode 100644 index 5c8c9a7..0000000 --- a/docs/build/html/index.html +++ /dev/null @@ -1,319 +0,0 @@ - - - - - - - - Predspot — Predspot 1.0 documentation - - - - - - - - - - - - - - - - - - -
- - -
-
- - - -
- -
-

Predspot

-
-

Overview

-

Predspot is an early project for a Python library for spatio-temporal crime prediction and hotspot detection. It combines machine learning techniques with spatial analysis to help predict and visualize crime patterns across time and space. To properly use this library, you preferably need to have a crime dataset and a study area.

-

The results of this library are not guaranteed to be good, but it is a good starting point for spatio-temporal crime prediction. This project is barely maintained, so please if you want to collaborate, open an issue or a PR.

-
-
-

Important Notice

-
-

Warning

-

This project was developed as part of a master’s thesis and is currently in an archived state. While the core functionality exists, you may encounter compatibility issues with newer Python package versions. The code can work with some effort, but please note:

-
    -
  • This is not production-ready software

  • -
  • Some dependencies are outdated and may require specific versions

  • -
  • You might need to modify some code to work with newer package versions

  • -
  • The project was created for research purposes

  • -
-

However, we believe the methodologies and approaches used here are still valuable! If you’re interested in crime hotspot prediction, feel free to:

-
    -
  • Use this as a reference implementation

  • -
  • Adapt the code to modern dependencies

  • -
  • Build upon these concepts for your own projects

  • -
  • Contribute to modernizing the codebase

  • -
-

We welcome anyone interested in reviving or learning from this project!

-
-
-
-

Key Features

-
    -
  • Spatial and temporal crime mapping

  • -
  • Feature engineering for time series data

  • -
  • Machine learning-based prediction pipeline

  • -
  • Crime hotspot detection using Kernel Density Estimation

  • -
  • Visualization tools for crime patterns

  • -
-
-
-

Quick Start

-

Basic usage example:

-
from predspot import Dataset, PredictionPipeline
-from predspot.crime_mapping import KDE, create_gridpoints
-from predspot.feature_engineering import Seasonality, Trend, Diff
-
-# Load and prepare data
-dataset = Dataset(crimes_df, study_area_gdf)
-
-# Create prediction pipeline
-pipeline = PredictionPipeline(
-    mapping=KDE(tfreq='M', grid=create_gridpoints(study_area, resolution=250)),
-    fextraction=PandasFeatureUnion([
-        ('seasonal', Seasonality(lags=12)),
-        ('trend', Trend(lags=12)),
-        ('diff', Diff(lags=12))
-    ]),
-    estimator=your_favorite_sklearn_model
-)
-
-# Fit and predict
-pipeline.fit(dataset)
-predictions = pipeline.predict()
-
-
-
-
-

Modules

-

The library consists of four main modules:

-
    -
  • Dataset Preparation: Module for preparing and managing crime datasets and study areas.

  • -
  • Crime Mapping: Module for spatial and temporal crime mapping, including KDE-based hotspot detection.

  • -
  • Feature Engineering: Module for time series feature engineering, including seasonality, trend, and difference features.

  • -
  • ML Modelling: Module that implements the prediction pipeline and model evaluation.

  • -
-

And two utilities:

-
    -
  • Utilities: Utility functions for data preparation and visualization.

  • -
  • Pipeline: Functions for the prediction pipeline.

  • -
-
-
-

Installation

-

Create conda env and install requirements:

-
conda create -n predspot python=3.8
-conda activate predspot
-conda install -y rtree geopandas  # if doesnt work, do: `conda clean --all`
-pip install pandas statsmodels==0.10.2 geojsoncontour stldecompose scikit-learn matplotlib descartes
-pip install .
-
-
-

Required dependencies:

-
    -
  • pandas

  • -
  • geopandas

  • -
  • numpy

  • -
  • scikit-learn

  • -
  • scipy

  • -
  • stldecompose

  • -
  • matplotlib

  • -
-
-
-

Input Data Format

-

The crime data should be a pandas DataFrame with the following required columns:

-
    -
  • tag: Crime type

  • -
  • t: Timestamp

  • -
  • lon: Longitude

  • -
  • lat: Latitude

  • -
-

The study area should be a GeoDataFrame defining the geographical boundaries of interest.

-
-
-

Resources

-

For more information on the methods used in Predspot, please search more about these methods:

-
    -
  • Kernel Density Estimation for crime hotspot detection

  • -
  • Time series decomposition for feature engineering

  • -
  • Spatio-temporal crime prediction techniques

  • -
-
-
-

License

-

BSD 3-Clause.

-
-
-

Contributing

-

Contributions are welcome! Please feel free to submit a Pull Request.

-

Guidelines for contributing:

-
    -
  1. Fork the repository

  2. -
  3. Create your feature branch

  4. -
  5. Commit your changes

  6. -
  7. Push to the branch

  8. -
  9. Create a new Pull Request

  10. -
-
-
-

Citation

-

If you use Predspot in your research, please cite us:

-
Araujo, A., & Cacho, N. (2019). Predspot: Predicting crime hotspots with machine learning.
-Master's thesis, UFRN (Universidade Federal do Rio Grande do Norte), Natal, Brazil.
-
-Araújo, A., Cacho, N., Bezerra, L., Vieira, C., & Borges, J. (2018, June).
-Towards a crime hotspot detection framework for patrol planning.
-In 2018 IEEE 20th International Conference on High Performance Computing and Communications;
-IEEE 16th International Conference on Smart City;
-IEEE 4th International Conference on Data Science and Systems (HPCC/SmartCity/DSS) (pp. 1256-1263). IEEE.
-
-
- -
-
-
-

Indices and tables

- -
- - -
- - -
-
-
-
- - - - - - - \ No newline at end of file diff --git a/docs/build/html/modules/crime_mapping.html b/docs/build/html/modules/crime_mapping.html deleted file mode 100644 index 871c3d2..0000000 --- a/docs/build/html/modules/crime_mapping.html +++ /dev/null @@ -1,630 +0,0 @@ - - - - - - - - Crime Mapping — Predspot 1.0 documentation - - - - - - - - - - - - - - - - - - - -
- - -
-
- - - -
- -
-

Crime Mapping

-

Crime Mapping Module

-

This module provides functionality for spatial and temporal crime mapping analysis. -It includes utilities for creating grid points, hexagonal grids, and implementing -kernel density estimation for crime hotspot detection.

-
-
-class predspot.crime_mapping.KDE(tfreq, grid, start_time=False, end_time=False, bandwidth='silverman', debug=False)[source]
-

Bases: SpatioTemporalMapping

-

Kernel Density Estimation for crime hotspot detection.

-
-
Parameters:
-
    -
  • tfreq (str) – Time frequency (‘M’ for monthly, ‘W’ for weekly, ‘D’ for daily)

  • -
  • grid (GeoDataFrame) – Spatial grid for analysis

  • -
  • start_time (str or datetime, optional) – Analysis start time

  • -
  • end_time (str or datetime, optional) – Analysis end time

  • -
  • bandwidth (str or float) – Bandwidth method (‘silverman’ or numeric value)

  • -
-
-
-
-
-fit_grid(data_points, as_df=False)[source]
-

Fit the kernel density estimation to grid points.

-
-
Parameters:
-
    -
  • data_points (GeoDataFrame) – Crime incident points

  • -
  • as_df (bool) – If True, return results as DataFrame

  • -
-
-
Returns:
-

Density estimates for grid points

-
-
Return type:
-

dict or DataFrame

-
-
-
- -
-
-set_fit_request(*, x: bool | None | str = '$UNCHANGED$') → KDE
-

Request metadata passed to the fit method.

-

Note that this method is only relevant if -enable_metadata_routing=True (see sklearn.set_config()). -Please see User Guide on how the routing -mechanism works.

-

The options for each parameter are:

-
    -
  • True: metadata is requested, and passed to fit if provided. The request is ignored if metadata is not provided.

  • -
  • False: metadata is not requested and the meta-estimator will not pass it to fit.

  • -
  • None: metadata is not requested, and the meta-estimator will raise an error if the user provides it.

  • -
  • str: metadata should be passed to the meta-estimator with this given alias instead of the original name.

  • -
-

The default (sklearn.utils.metadata_routing.UNCHANGED) retains the -existing request. This allows you to change the request for some -parameters and not others.

-
-

Added in version 1.3.

-
-
-

Note

-

This method is only relevant if this estimator is used as a -sub-estimator of a meta-estimator, e.g. used inside a -Pipeline. Otherwise it has no effect.

-
-
-
Parameters:
-

x (str, True, False, or None, default=sklearn.utils.metadata_routing.UNCHANGED) – Metadata routing for x parameter in fit.

-
-
Returns:
-

self – The updated object.

-
-
Return type:
-

object

-
-
-
- -
-
-set_transform_request(*, data_points: bool | None | str = '$UNCHANGED$') → KDE
-

Request metadata passed to the transform method.

-

Note that this method is only relevant if -enable_metadata_routing=True (see sklearn.set_config()). -Please see User Guide on how the routing -mechanism works.

-

The options for each parameter are:

-
    -
  • True: metadata is requested, and passed to transform if provided. The request is ignored if metadata is not provided.

  • -
  • False: metadata is not requested and the meta-estimator will not pass it to transform.

  • -
  • None: metadata is not requested, and the meta-estimator will raise an error if the user provides it.

  • -
  • str: metadata should be passed to the meta-estimator with this given alias instead of the original name.

  • -
-

The default (sklearn.utils.metadata_routing.UNCHANGED) retains the -existing request. This allows you to change the request for some -parameters and not others.

-
-

Added in version 1.3.

-
-
-

Note

-

This method is only relevant if this estimator is used as a -sub-estimator of a meta-estimator, e.g. used inside a -Pipeline. Otherwise it has no effect.

-
-
-
Parameters:
-

data_points (str, True, False, or None, default=sklearn.utils.metadata_routing.UNCHANGED) – Metadata routing for data_points parameter in transform.

-
-
Returns:
-

self – The updated object.

-
-
Return type:
-

object

-
-
-
- -
- -
-
-class predspot.crime_mapping.KGrid(k, tfreq)[source]
-

Bases: object

-
-
-fit(data_points)[source]
-
- -
-
-transform(data_points)[source]
-
- -
- -
-
-class predspot.crime_mapping.QuadratCount(tfreq, grid, filter_place_ratio=0.9)[source]
-

Bases: BaseEstimator, TransformerMixin

-
-
-fit(x=None, y=None)[source]
-
- -
-
-set_fit_request(*, x: bool | None | str = '$UNCHANGED$') → QuadratCount
-

Request metadata passed to the fit method.

-

Note that this method is only relevant if -enable_metadata_routing=True (see sklearn.set_config()). -Please see User Guide on how the routing -mechanism works.

-

The options for each parameter are:

-
    -
  • True: metadata is requested, and passed to fit if provided. The request is ignored if metadata is not provided.

  • -
  • False: metadata is not requested and the meta-estimator will not pass it to fit.

  • -
  • None: metadata is not requested, and the meta-estimator will raise an error if the user provides it.

  • -
  • str: metadata should be passed to the meta-estimator with this given alias instead of the original name.

  • -
-

The default (sklearn.utils.metadata_routing.UNCHANGED) retains the -existing request. This allows you to change the request for some -parameters and not others.

-
-

Added in version 1.3.

-
-
-

Note

-

This method is only relevant if this estimator is used as a -sub-estimator of a meta-estimator, e.g. used inside a -Pipeline. Otherwise it has no effect.

-
-
-
Parameters:
-

x (str, True, False, or None, default=sklearn.utils.metadata_routing.UNCHANGED) – Metadata routing for x parameter in fit.

-
-
Returns:
-

self – The updated object.

-
-
Return type:
-

object

-
-
-
- -
-
-set_transform_request(*, data_points: bool | None | str = '$UNCHANGED$') → QuadratCount
-

Request metadata passed to the transform method.

-

Note that this method is only relevant if -enable_metadata_routing=True (see sklearn.set_config()). -Please see User Guide on how the routing -mechanism works.

-

The options for each parameter are:

-
    -
  • True: metadata is requested, and passed to transform if provided. The request is ignored if metadata is not provided.

  • -
  • False: metadata is not requested and the meta-estimator will not pass it to transform.

  • -
  • None: metadata is not requested, and the meta-estimator will raise an error if the user provides it.

  • -
  • str: metadata should be passed to the meta-estimator with this given alias instead of the original name.

  • -
-

The default (sklearn.utils.metadata_routing.UNCHANGED) retains the -existing request. This allows you to change the request for some -parameters and not others.

-
-

Added in version 1.3.

-
-
-

Note

-

This method is only relevant if this estimator is used as a -sub-estimator of a meta-estimator, e.g. used inside a -Pipeline. Otherwise it has no effect.

-
-
-
Parameters:
-

data_points (str, True, False, or None, default=sklearn.utils.metadata_routing.UNCHANGED) – Metadata routing for data_points parameter in transform.

-
-
Returns:
-

self – The updated object.

-
-
Return type:
-

object

-
-
-
- -
-
-transform(data_points)[source]
-
- -
- -
-
-class predspot.crime_mapping.QuadratCount2(tfreq, grid, filter_place_ratio=0.9)[source]
-

Bases: BaseEstimator, TransformerMixin

-
-
-set_transform_request(*, data_points: bool | None | str = '$UNCHANGED$') → QuadratCount2
-

Request metadata passed to the transform method.

-

Note that this method is only relevant if -enable_metadata_routing=True (see sklearn.set_config()). -Please see User Guide on how the routing -mechanism works.

-

The options for each parameter are:

-
    -
  • True: metadata is requested, and passed to transform if provided. The request is ignored if metadata is not provided.

  • -
  • False: metadata is not requested and the meta-estimator will not pass it to transform.

  • -
  • None: metadata is not requested, and the meta-estimator will raise an error if the user provides it.

  • -
  • str: metadata should be passed to the meta-estimator with this given alias instead of the original name.

  • -
-

The default (sklearn.utils.metadata_routing.UNCHANGED) retains the -existing request. This allows you to change the request for some -parameters and not others.

-
-

Added in version 1.3.

-
-
-

Note

-

This method is only relevant if this estimator is used as a -sub-estimator of a meta-estimator, e.g. used inside a -Pipeline. Otherwise it has no effect.

-
-
-
Parameters:
-

data_points (str, True, False, or None, default=sklearn.utils.metadata_routing.UNCHANGED) – Metadata routing for data_points parameter in transform.

-
-
Returns:
-

self – The updated object.

-
-
Return type:
-

object

-
-
-
- -
-
-transform(data_points)[source]
-
- -
- -
-
-class predspot.crime_mapping.SpatioTemporalMapping(tfreq, grid, start_time=False, end_time=False, debug=False)[source]
-

Bases: ABC, TransformerMixin, BaseEstimator

-

Abstract base class for spatio-temporal crime mapping.

-
-
Parameters:
-
    -
  • tfreq (str) – Time frequency (‘M’ for monthly, ‘W’ for weekly, ‘D’ for daily)

  • -
  • grid (GeoDataFrame) – Spatial grid for analysis

  • -
  • start_time (str or datetime, optional) – Analysis start time

  • -
  • end_time (str or datetime, optional) – Analysis end time

  • -
-
-
-
-
-fit(x, y=None)[source]
-
- -
-
-abstract fit_grid(data_points=None)[source]
-
- -
-
-get_time_data_chunks(data_points)[source]
-
- -
-
-get_times_no_data(chunks)[source]
-
- -
-
-set_fit_request(*, x: bool | None | str = '$UNCHANGED$') → SpatioTemporalMapping
-

Request metadata passed to the fit method.

-

Note that this method is only relevant if -enable_metadata_routing=True (see sklearn.set_config()). -Please see User Guide on how the routing -mechanism works.

-

The options for each parameter are:

-
    -
  • True: metadata is requested, and passed to fit if provided. The request is ignored if metadata is not provided.

  • -
  • False: metadata is not requested and the meta-estimator will not pass it to fit.

  • -
  • None: metadata is not requested, and the meta-estimator will raise an error if the user provides it.

  • -
  • str: metadata should be passed to the meta-estimator with this given alias instead of the original name.

  • -
-

The default (sklearn.utils.metadata_routing.UNCHANGED) retains the -existing request. This allows you to change the request for some -parameters and not others.

-
-

Added in version 1.3.

-
-
-

Note

-

This method is only relevant if this estimator is used as a -sub-estimator of a meta-estimator, e.g. used inside a -Pipeline. Otherwise it has no effect.

-
-
-
Parameters:
-

x (str, True, False, or None, default=sklearn.utils.metadata_routing.UNCHANGED) – Metadata routing for x parameter in fit.

-
-
Returns:
-

self – The updated object.

-
-
Return type:
-

object

-
-
-
- -
-
-set_transform_request(*, data_points: bool | None | str = '$UNCHANGED$') → SpatioTemporalMapping
-

Request metadata passed to the transform method.

-

Note that this method is only relevant if -enable_metadata_routing=True (see sklearn.set_config()). -Please see User Guide on how the routing -mechanism works.

-

The options for each parameter are:

-
    -
  • True: metadata is requested, and passed to transform if provided. The request is ignored if metadata is not provided.

  • -
  • False: metadata is not requested and the meta-estimator will not pass it to transform.

  • -
  • None: metadata is not requested, and the meta-estimator will raise an error if the user provides it.

  • -
  • str: metadata should be passed to the meta-estimator with this given alias instead of the original name.

  • -
-

The default (sklearn.utils.metadata_routing.UNCHANGED) retains the -existing request. This allows you to change the request for some -parameters and not others.

-
-

Added in version 1.3.

-
-
-

Note

-

This method is only relevant if this estimator is used as a -sub-estimator of a meta-estimator, e.g. used inside a -Pipeline. Otherwise it has no effect.

-
-
-
Parameters:
-

data_points (str, True, False, or None, default=sklearn.utils.metadata_routing.UNCHANGED) – Metadata routing for data_points parameter in transform.

-
-
Returns:
-

self – The updated object.

-
-
Return type:
-

object

-
-
-
- -
-
-transform(data_points)[source]
-
- -
- -
-
-predspot.crime_mapping.create_gridhexagonal(bbox, resolution)[source]
-
- -
-
-predspot.crime_mapping.create_gridpoints(bbox, resolution, return_coords=False, debug=False)[source]
-

Create a grid of points within a given bounding box.

-
-
Parameters:
-
    -
  • bbox (GeoDataFrame) – Bounding box as a GeoDataFrame

  • -
  • resolution (float) – Grid cell size in kilometers

  • -
  • return_coords (bool) – If True, returns additional coordinate arrays

  • -
  • debug (bool) – Enable debug printing

  • -
-
-
Returns:
-

Grid points as GeoDataFrame, optionally with coordinate arrays

-
-
Return type:
-

GeoDataFrame or tuple

-
-
-
- -
-
-predspot.crime_mapping.create_gridsquares(city_shape, resolution=1)[source]
-

It constructs a grid of square cells.

-
-
Parameters:
-
    -
  • city_shape (GeoDataFrame.) – Corresponds to the boundary geometry in which the grid will be formed.

  • -
  • resolution (float, default is 1.) – Space between the square cells.

  • -
-
-
-
- -
-
-predspot.crime_mapping.create_hexagon(l, x, y)[source]
-

Create a hexagonal polygon.

-
-
Parameters:
-
    -
  • l (float) – Length of hexagon side

  • -
  • x (float) – X-coordinate of center

  • -
  • y (float) – Y-coordinate of center

  • -
-
-
Returns:
-

Hexagonal polygon

-
-
Return type:
-

Polygon

-
-
-
- -
- - -
- - -
-
-
-
- - - - - - - \ No newline at end of file diff --git a/docs/build/html/modules/dataset_preparation.html b/docs/build/html/modules/dataset_preparation.html deleted file mode 100644 index 57bd0be..0000000 --- a/docs/build/html/modules/dataset_preparation.html +++ /dev/null @@ -1,289 +0,0 @@ - - - - - - - - Dataset Preparation — Predspot 1.0 documentation - - - - - - - - - - - - - - - - - - - -
- - -
-
- - - -
- -
-

Dataset Preparation

-

Dataset Preparation Module

-

This module provides functionality for preparing and managing crime datasets along with their -corresponding study areas. It handles spatial data processing and visualization of crime incidents.

-
-
-class predspot.dataset_preparation.Dataset(crimes, study_area, debug=False)[source]
-

Bases: object

-

A class to handle crime datasets and their associated study areas.

-
-
Parameters:
-
    -
  • crimes (pandas.DataFrame) – DataFrame containing crime data with required columns: -‘tag’, ‘t’ (timestamp), ‘lon’ (longitude), and ‘lat’ (latitude)

  • -
  • study_area (geopandas.GeoDataFrame) – GeoDataFrame defining the study area boundaries

  • -
  • debug (bool, optional) – Enable debug printing. Defaults to False.

  • -
-
-
-
-
-crimes
-

Processed crime data with geometry

-
-
Type:
-

geopandas.GeoDataFrame

-
-
-
- -
-
-study_area
-

Study area boundaries

-
-
Type:
-

geopandas.GeoDataFrame

-
-
-
- -
-
-property crimes
-

Get the crime incidents data.

-
-
Returns:
-

The processed crime incidents data

-
-
Return type:
-

geopandas.GeoDataFrame

-
-
-
- -
-
-plot(ax=None, crime_samples=1000, **kwargs)[source]
-

Plot the study area and crime incidents.

-
-
Parameters:
-
    -
  • ax (matplotlib.axes.Axes, optional) – Matplotlib axes for plotting

  • -
  • crime_samples (int, optional) – Number of crime samples to plot. Defaults to 1000

  • -
  • **kwargs – Additional keyword arguments for plotting -study_area: kwargs for study area plot -crimes: kwargs for crime incidents plot

  • -
-
-
Returns:
-

The plot axes

-
-
Return type:
-

matplotlib.axes.Axes

-
-
-
- -
-
-property shape
-

Get the shapes of the dataset components.

-
-
Returns:
-

Dictionary containing the shapes of crimes and study_area DataFrames

-
-
Return type:
-

dict

-
-
-
- -
-
-property study_area
-

Get the study area boundaries.

-
-
Returns:
-

The study area boundaries

-
-
Return type:
-

geopandas.GeoDataFrame

-
-
-
- -
-
-train_test_split(test_size=0.25)[source]
-

Split the dataset into training and testing sets.

-
-
Parameters:
-

test_size (float) – Proportion of the dataset to include in the test split. -Must be between 0 and 1. Defaults to 0.25.

-
-
Returns:
-

(train_dataset, test_dataset) - Two Dataset objects containing the splits

-
-
Return type:
-

tuple

-
-
Raises:
-

AssertionError – If test_size is not between 0 and 1

-
-
-
- -
- -
- - -
- - -
-
-
-
- - - - - - - \ No newline at end of file diff --git a/docs/build/html/modules/feature_engineering.html b/docs/build/html/modules/feature_engineering.html deleted file mode 100644 index 7895ccc..0000000 --- a/docs/build/html/modules/feature_engineering.html +++ /dev/null @@ -1,671 +0,0 @@ - - - - - - - - Feature Engineering — Predspot 1.0 documentation - - - - - - - - - - - - - - - - - - - -
- - -
-
- - - -
- -
-

Feature Engineering

-

Feature Engineering Module

-

This module provides classes for time series feature engineering and transformation, -including autoregressive features, differencing, seasonality, and trend decomposition.

-
-
-class predspot.feature_engineering.AR(lags, tfreq, debug=False)[source]
-

Bases: TimeSeriesFeatures

-

Autoregressive features implementation.

-
-
-apply_ts_decomposition(ts)[source]
-

Apply autoregressive transformation (identity).

-
-
Parameters:
-

ts (pandas.Series) – Input time series

-
-
Returns:
-

Original time series

-
-
Return type:
-

pandas.Series

-
-
-
- -
-
-property label
-

Feature label for autoregressive features

-
-
Type:
-

str

-
-
-
- -
-
-set_transform_request(*, stseries: bool | None | str = '$UNCHANGED$') → AR
-

Request metadata passed to the transform method.

-

Note that this method is only relevant if -enable_metadata_routing=True (see sklearn.set_config()). -Please see User Guide on how the routing -mechanism works.

-

The options for each parameter are:

-
    -
  • True: metadata is requested, and passed to transform if provided. The request is ignored if metadata is not provided.

  • -
  • False: metadata is not requested and the meta-estimator will not pass it to transform.

  • -
  • None: metadata is not requested, and the meta-estimator will raise an error if the user provides it.

  • -
  • str: metadata should be passed to the meta-estimator with this given alias instead of the original name.

  • -
-

The default (sklearn.utils.metadata_routing.UNCHANGED) retains the -existing request. This allows you to change the request for some -parameters and not others.

-
-

Added in version 1.3.

-
-
-

Note

-

This method is only relevant if this estimator is used as a -sub-estimator of a meta-estimator, e.g. used inside a -Pipeline. Otherwise it has no effect.

-
-
-
Parameters:
-

stseries (str, True, False, or None, default=sklearn.utils.metadata_routing.UNCHANGED) – Metadata routing for stseries parameter in transform.

-
-
Returns:
-

self – The updated object.

-
-
Return type:
-

object

-
-
-
- -
- -
-
-class predspot.feature_engineering.Diff(lags, tfreq, debug=False)[source]
-

Bases: TimeSeriesFeatures

-

Difference features implementation.

-
-
-apply_ts_decomposition(ts)[source]
-

Apply difference transformation.

-
-
Parameters:
-

ts (pandas.Series) – Input time series

-
-
Returns:
-

Differenced time series

-
-
Return type:
-

pandas.Series

-
-
-
- -
-
-property label
-

Feature label for difference features

-
-
Type:
-

str

-
-
-
- -
-
-set_transform_request(*, stseries: bool | None | str = '$UNCHANGED$') → Diff
-

Request metadata passed to the transform method.

-

Note that this method is only relevant if -enable_metadata_routing=True (see sklearn.set_config()). -Please see User Guide on how the routing -mechanism works.

-

The options for each parameter are:

-
    -
  • True: metadata is requested, and passed to transform if provided. The request is ignored if metadata is not provided.

  • -
  • False: metadata is not requested and the meta-estimator will not pass it to transform.

  • -
  • None: metadata is not requested, and the meta-estimator will raise an error if the user provides it.

  • -
  • str: metadata should be passed to the meta-estimator with this given alias instead of the original name.

  • -
-

The default (sklearn.utils.metadata_routing.UNCHANGED) retains the -existing request. This allows you to change the request for some -parameters and not others.

-
-

Added in version 1.3.

-
-
-

Note

-

This method is only relevant if this estimator is used as a -sub-estimator of a meta-estimator, e.g. used inside a -Pipeline. Otherwise it has no effect.

-
-
-
Parameters:
-

stseries (str, True, False, or None, default=sklearn.utils.metadata_routing.UNCHANGED) – Metadata routing for stseries parameter in transform.

-
-
Returns:
-

self – The updated object.

-
-
Return type:
-

object

-
-
-
- -
- -
-
-class predspot.feature_engineering.FeatureScaling(estimator, debug=False)[source]
-

Bases: TransformerMixin, BaseEstimator

-

Feature scaling transformer.

-
-
Parameters:
-
    -
  • estimator – Scikit-learn compatible scaling estimator

  • -
  • debug (bool, optional) – Enable debug printing. Defaults to False

  • -
-
-
-
-
-set_transform_request(*, x: bool | None | str = '$UNCHANGED$') → FeatureScaling
-

Request metadata passed to the transform method.

-

Note that this method is only relevant if -enable_metadata_routing=True (see sklearn.set_config()). -Please see User Guide on how the routing -mechanism works.

-

The options for each parameter are:

-
    -
  • True: metadata is requested, and passed to transform if provided. The request is ignored if metadata is not provided.

  • -
  • False: metadata is not requested and the meta-estimator will not pass it to transform.

  • -
  • None: metadata is not requested, and the meta-estimator will raise an error if the user provides it.

  • -
  • str: metadata should be passed to the meta-estimator with this given alias instead of the original name.

  • -
-

The default (sklearn.utils.metadata_routing.UNCHANGED) retains the -existing request. This allows you to change the request for some -parameters and not others.

-
-

Added in version 1.3.

-
-
-

Note

-

This method is only relevant if this estimator is used as a -sub-estimator of a meta-estimator, e.g. used inside a -Pipeline. Otherwise it has no effect.

-
-
-
Parameters:
-

x (str, True, False, or None, default=sklearn.utils.metadata_routing.UNCHANGED) – Metadata routing for x parameter in transform.

-
-
Returns:
-

self – The updated object.

-
-
Return type:
-

object

-
-
-
- -
-
-transform(x)[source]
-

Transform features using the scaling estimator.

-
-
Parameters:
-

x (pandas.DataFrame) – Input features

-
-
Returns:
-

Scaled features

-
-
Return type:
-

pandas.DataFrame

-
-
-
- -
- -
-
-class predspot.feature_engineering.Seasonality(lags, tfreq, debug=False)[source]
-

Bases: TimeSeriesFeatures

-

Seasonal decomposition features implementation.

-
-
-apply_ts_decomposition(ts)[source]
-

Extract seasonal component from time series.

-
-
Parameters:
-

ts (pandas.Series) – Input time series

-
-
Returns:
-

Seasonal component

-
-
Return type:
-

pandas.Series

-
-
-
- -
-
-property label
-

Feature label for seasonal features

-
-
Type:
-

str

-
-
-
- -
-
-set_transform_request(*, stseries: bool | None | str = '$UNCHANGED$') → Seasonality
-

Request metadata passed to the transform method.

-

Note that this method is only relevant if -enable_metadata_routing=True (see sklearn.set_config()). -Please see User Guide on how the routing -mechanism works.

-

The options for each parameter are:

-
    -
  • True: metadata is requested, and passed to transform if provided. The request is ignored if metadata is not provided.

  • -
  • False: metadata is not requested and the meta-estimator will not pass it to transform.

  • -
  • None: metadata is not requested, and the meta-estimator will raise an error if the user provides it.

  • -
  • str: metadata should be passed to the meta-estimator with this given alias instead of the original name.

  • -
-

The default (sklearn.utils.metadata_routing.UNCHANGED) retains the -existing request. This allows you to change the request for some -parameters and not others.

-
-

Added in version 1.3.

-
-
-

Note

-

This method is only relevant if this estimator is used as a -sub-estimator of a meta-estimator, e.g. used inside a -Pipeline. Otherwise it has no effect.

-
-
-
Parameters:
-

stseries (str, True, False, or None, default=sklearn.utils.metadata_routing.UNCHANGED) – Metadata routing for stseries parameter in transform.

-
-
Returns:
-

self – The updated object.

-
-
Return type:
-

object

-
-
-
- -
- -
-
-class predspot.feature_engineering.TimeSeriesFeatures(lags, tfreq, debug=False)[source]
-

Bases: BaseEstimator, TransformerMixin

-

Base class for time series feature engineering.

-
-
Parameters:
-
    -
  • lags (int) – Number of time lags to use for feature creation

  • -
  • tfreq (str) – Time frequency (‘D’ for daily, ‘W’ for weekly, ‘M’ for monthly)

  • -
  • debug (bool, optional) – Enable debug printing. Defaults to False

  • -
-
-
Raises:
-

AssertionError – If lags is not a positive integer or tfreq is invalid

-
-
-
-
-abstract apply_ts_decomposition(ts)[source]
-

Apply time series decomposition.

-
-
Parameters:
-

ts (pandas.Series) – Input time series

-
-
Returns:
-

Transformed time series

-
-
Return type:
-

pandas.Series

-
-
-
- -
-
-property label
-

Feature label identifier

-
-
Type:
-

str

-
-
-
- -
-
-property lags
-

Number of time lags

-
-
Type:
-

int

-
-
-
- -
-
-make_lag_df(ts)[source]
-

Create lagged features dataframe.

-
-
Parameters:
-

ts (pandas.Series) – Input time series

-
-
Returns:
-

(lag_df, aligned_ts) - Lagged features and aligned original series

-
-
Return type:
-

tuple

-
-
Raises:
-

AssertionError – If series length is less than number of lags

-
-
-
- -
-
-set_transform_request(*, stseries: bool | None | str = '$UNCHANGED$') → TimeSeriesFeatures
-

Request metadata passed to the transform method.

-

Note that this method is only relevant if -enable_metadata_routing=True (see sklearn.set_config()). -Please see User Guide on how the routing -mechanism works.

-

The options for each parameter are:

-
    -
  • True: metadata is requested, and passed to transform if provided. The request is ignored if metadata is not provided.

  • -
  • False: metadata is not requested and the meta-estimator will not pass it to transform.

  • -
  • None: metadata is not requested, and the meta-estimator will raise an error if the user provides it.

  • -
  • str: metadata should be passed to the meta-estimator with this given alias instead of the original name.

  • -
-

The default (sklearn.utils.metadata_routing.UNCHANGED) retains the -existing request. This allows you to change the request for some -parameters and not others.

-
-

Added in version 1.3.

-
-
-

Note

-

This method is only relevant if this estimator is used as a -sub-estimator of a meta-estimator, e.g. used inside a -Pipeline. Otherwise it has no effect.

-
-
-
Parameters:
-

stseries (str, True, False, or None, default=sklearn.utils.metadata_routing.UNCHANGED) – Metadata routing for stseries parameter in transform.

-
-
Returns:
-

self – The updated object.

-
-
Return type:
-

object

-
-
-
- -
-
-transform(stseries)[source]
-

Transform the input series into lagged features.

-
-
Parameters:
-

stseries (pandas.Series) – Input time series with multi-index (time, places)

-
-
Returns:
-

Transformed features

-
-
Return type:
-

pandas.DataFrame

-
-
-
- -
- -
-
-class predspot.feature_engineering.Trend(lags, tfreq, debug=False)[source]
-

Bases: TimeSeriesFeatures

-

Trend decomposition features implementation.

-
-
-apply_ts_decomposition(ts)[source]
-

Extract trend component from time series.

-
-
Parameters:
-

ts (pandas.Series) – Input time series

-
-
Returns:
-

Trend component

-
-
Return type:
-

pandas.Series

-
-
-
- -
-
-property label
-

Feature label for trend features

-
-
Type:
-

str

-
-
-
- -
-
-set_transform_request(*, stseries: bool | None | str = '$UNCHANGED$') → Trend
-

Request metadata passed to the transform method.

-

Note that this method is only relevant if -enable_metadata_routing=True (see sklearn.set_config()). -Please see User Guide on how the routing -mechanism works.

-

The options for each parameter are:

-
    -
  • True: metadata is requested, and passed to transform if provided. The request is ignored if metadata is not provided.

  • -
  • False: metadata is not requested and the meta-estimator will not pass it to transform.

  • -
  • None: metadata is not requested, and the meta-estimator will raise an error if the user provides it.

  • -
  • str: metadata should be passed to the meta-estimator with this given alias instead of the original name.

  • -
-

The default (sklearn.utils.metadata_routing.UNCHANGED) retains the -existing request. This allows you to change the request for some -parameters and not others.

-
-

Added in version 1.3.

-
-
-

Note

-

This method is only relevant if this estimator is used as a -sub-estimator of a meta-estimator, e.g. used inside a -Pipeline. Otherwise it has no effect.

-
-
-
Parameters:
-

stseries (str, True, False, or None, default=sklearn.utils.metadata_routing.UNCHANGED) – Metadata routing for stseries parameter in transform.

-
-
Returns:
-

self – The updated object.

-
-
Return type:
-

object

-
-
-
- -
- -
- - -
- - -
-
-
-
- - - - - - - \ No newline at end of file diff --git a/docs/build/html/modules/ml_modelling.html b/docs/build/html/modules/ml_modelling.html deleted file mode 100644 index a31436a..0000000 --- a/docs/build/html/modules/ml_modelling.html +++ /dev/null @@ -1,675 +0,0 @@ - - - - - - - - ML Modelling — Predspot 1.0 documentation - - - - - - - - - - - - - - - - - - - -
- - -
-
- - - -
- -
-

ML Modelling

-

Machine Learning Modelling Module

-

This module provides classes for machine learning model pipelines, feature selection, -and prediction functionality for crime density forecasting.

-
-
-class predspot.ml_modelling.FeatureSelection(estimator, debug=False)[source]
-

Bases: TransformerMixin, BaseEstimator

-

Feature selection transformer.

-
-
Parameters:
-
    -
  • estimator – Scikit-learn compatible feature selector

  • -
  • debug (bool, optional) – Enable debug printing. Defaults to False

  • -
-
-
-
-
-fit(x, y=None)[source]
-

Fit the feature selector.

-
-
Parameters:
-
    -
  • x (pandas.DataFrame) – Input features

  • -
  • y (pandas.Series, optional) – Target variable

  • -
-
-
Returns:
-

The fitted instance

-
-
Return type:
-

self

-
-
-
- -
-
-set_fit_request(*, x: bool | None | str = '$UNCHANGED$') → FeatureSelection
-

Request metadata passed to the fit method.

-

Note that this method is only relevant if -enable_metadata_routing=True (see sklearn.set_config()). -Please see User Guide on how the routing -mechanism works.

-

The options for each parameter are:

-
    -
  • True: metadata is requested, and passed to fit if provided. The request is ignored if metadata is not provided.

  • -
  • False: metadata is not requested and the meta-estimator will not pass it to fit.

  • -
  • None: metadata is not requested, and the meta-estimator will raise an error if the user provides it.

  • -
  • str: metadata should be passed to the meta-estimator with this given alias instead of the original name.

  • -
-

The default (sklearn.utils.metadata_routing.UNCHANGED) retains the -existing request. This allows you to change the request for some -parameters and not others.

-
-

Added in version 1.3.

-
-
-

Note

-

This method is only relevant if this estimator is used as a -sub-estimator of a meta-estimator, e.g. used inside a -Pipeline. Otherwise it has no effect.

-
-
-
Parameters:
-

x (str, True, False, or None, default=sklearn.utils.metadata_routing.UNCHANGED) – Metadata routing for x parameter in fit.

-
-
Returns:
-

self – The updated object.

-
-
Return type:
-

object

-
-
-
- -
-
-set_transform_request(*, x: bool | None | str = '$UNCHANGED$') → FeatureSelection
-

Request metadata passed to the transform method.

-

Note that this method is only relevant if -enable_metadata_routing=True (see sklearn.set_config()). -Please see User Guide on how the routing -mechanism works.

-

The options for each parameter are:

-
    -
  • True: metadata is requested, and passed to transform if provided. The request is ignored if metadata is not provided.

  • -
  • False: metadata is not requested and the meta-estimator will not pass it to transform.

  • -
  • None: metadata is not requested, and the meta-estimator will raise an error if the user provides it.

  • -
  • str: metadata should be passed to the meta-estimator with this given alias instead of the original name.

  • -
-

The default (sklearn.utils.metadata_routing.UNCHANGED) retains the -existing request. This allows you to change the request for some -parameters and not others.

-
-

Added in version 1.3.

-
-
-

Note

-

This method is only relevant if this estimator is used as a -sub-estimator of a meta-estimator, e.g. used inside a -Pipeline. Otherwise it has no effect.

-
-
-
Parameters:
-

x (str, True, False, or None, default=sklearn.utils.metadata_routing.UNCHANGED) – Metadata routing for x parameter in transform.

-
-
Returns:
-

self – The updated object.

-
-
Return type:
-

object

-
-
-
- -
-
-transform(x)[source]
-

Transform features using the feature selector.

-
-
Parameters:
-

x (pandas.DataFrame) – Input features

-
-
Returns:
-

Selected features

-
-
Return type:
-

pandas.DataFrame

-
-
-
- -
- -
-
-class predspot.ml_modelling.Model(estimator, debug=False)[source]
-

Bases: RegressorMixin, BaseEstimator

-

Model wrapper for crime density prediction.

-
-
Parameters:
-
    -
  • estimator – Scikit-learn compatible regression estimator

  • -
  • debug (bool, optional) – Enable debug printing. Defaults to False

  • -
-
-
-
-
-fit(x, y=None)[source]
-

Fit the regression model.

-
-
Parameters:
-
    -
  • x (pandas.DataFrame) – Input features

  • -
  • y (pandas.Series, optional) – Target variable

  • -
-
-
Returns:
-

The fitted instance

-
-
Return type:
-

self

-
-
-
- -
-
-predict(x)[source]
-

Make predictions using the fitted model.

-
-
Parameters:
-

x (pandas.DataFrame) – Input features

-
-
Returns:
-

Predictions with ‘crime_density’ column

-
-
Return type:
-

pandas.DataFrame

-
-
-
- -
-
-set_fit_request(*, x: bool | None | str = '$UNCHANGED$') → Model
-

Request metadata passed to the fit method.

-

Note that this method is only relevant if -enable_metadata_routing=True (see sklearn.set_config()). -Please see User Guide on how the routing -mechanism works.

-

The options for each parameter are:

-
    -
  • True: metadata is requested, and passed to fit if provided. The request is ignored if metadata is not provided.

  • -
  • False: metadata is not requested and the meta-estimator will not pass it to fit.

  • -
  • None: metadata is not requested, and the meta-estimator will raise an error if the user provides it.

  • -
  • str: metadata should be passed to the meta-estimator with this given alias instead of the original name.

  • -
-

The default (sklearn.utils.metadata_routing.UNCHANGED) retains the -existing request. This allows you to change the request for some -parameters and not others.

-
-

Added in version 1.3.

-
-
-

Note

-

This method is only relevant if this estimator is used as a -sub-estimator of a meta-estimator, e.g. used inside a -Pipeline. Otherwise it has no effect.

-
-
-
Parameters:
-

x (str, True, False, or None, default=sklearn.utils.metadata_routing.UNCHANGED) – Metadata routing for x parameter in fit.

-
-
Returns:
-

self – The updated object.

-
-
Return type:
-

object

-
-
-
- -
-
-set_predict_request(*, x: bool | None | str = '$UNCHANGED$') → Model
-

Request metadata passed to the predict method.

-

Note that this method is only relevant if -enable_metadata_routing=True (see sklearn.set_config()). -Please see User Guide on how the routing -mechanism works.

-

The options for each parameter are:

-
    -
  • True: metadata is requested, and passed to predict if provided. The request is ignored if metadata is not provided.

  • -
  • False: metadata is not requested and the meta-estimator will not pass it to predict.

  • -
  • None: metadata is not requested, and the meta-estimator will raise an error if the user provides it.

  • -
  • str: metadata should be passed to the meta-estimator with this given alias instead of the original name.

  • -
-

The default (sklearn.utils.metadata_routing.UNCHANGED) retains the -existing request. This allows you to change the request for some -parameters and not others.

-
-

Added in version 1.3.

-
-
-

Note

-

This method is only relevant if this estimator is used as a -sub-estimator of a meta-estimator, e.g. used inside a -Pipeline. Otherwise it has no effect.

-
-
-
Parameters:
-

x (str, True, False, or None, default=sklearn.utils.metadata_routing.UNCHANGED) – Metadata routing for x parameter in predict.

-
-
Returns:
-

self – The updated object.

-
-
Return type:
-

object

-
-
-
- -
-
-set_score_request(*, sample_weight: bool | None | str = '$UNCHANGED$') → Model
-

Request metadata passed to the score method.

-

Note that this method is only relevant if -enable_metadata_routing=True (see sklearn.set_config()). -Please see User Guide on how the routing -mechanism works.

-

The options for each parameter are:

-
    -
  • True: metadata is requested, and passed to score if provided. The request is ignored if metadata is not provided.

  • -
  • False: metadata is not requested and the meta-estimator will not pass it to score.

  • -
  • None: metadata is not requested, and the meta-estimator will raise an error if the user provides it.

  • -
  • str: metadata should be passed to the meta-estimator with this given alias instead of the original name.

  • -
-

The default (sklearn.utils.metadata_routing.UNCHANGED) retains the -existing request. This allows you to change the request for some -parameters and not others.

-
-

Added in version 1.3.

-
-
-

Note

-

This method is only relevant if this estimator is used as a -sub-estimator of a meta-estimator, e.g. used inside a -Pipeline. Otherwise it has no effect.

-
-
-
Parameters:
-

sample_weight (str, True, False, or None, default=sklearn.utils.metadata_routing.UNCHANGED) – Metadata routing for sample_weight parameter in score.

-
-
Returns:
-

self – The updated object.

-
-
Return type:
-

object

-
-
-
- -
- -
-
-class predspot.ml_modelling.PredictionPipeline(mapping, fextraction, estimator, debug=False)[source]
-

Bases: RegressorMixin, BaseEstimator

-

Complete pipeline for crime density prediction.

-
-
Parameters:
-
    -
  • mapping – Spatial mapping transformer

  • -
  • fextraction – Feature extraction transformer

  • -
  • estimator – Scikit-learn compatible pipeline or estimator

  • -
  • debug (bool, optional) – Enable debug printing. Defaults to False

  • -
-
-
-
-
-property dataset
-

Current dataset being used

-
-
Type:
-

Dataset

-
-
-
- -
-
-evaluate(scoring, cv=5)[source]
-

Evaluate model performance using time series cross-validation.

-
-
Parameters:
-
    -
  • scoring (str) – Scoring metric (‘r2’ or ‘mse’)

  • -
  • cv (int) – Number of cross-validation folds

  • -
-
-
Returns:
-

Scores for each fold

-
-
Return type:
-

list

-
-
Raises:
-

Exception – If scoring metric is invalid

-
-
-
- -
-
-property feature_importances
-

Get feature importance scores.

-
-
Returns:
-

Feature importance scores

-
-
Return type:
-

pandas.DataFrame

-
-
Raises:
-

Exception – If model hasn’t been fitted or doesn’t support feature importances

-
-
-
- -
-
-fit(dataset, y=None)[source]
-

Fit the complete prediction pipeline.

-
-
Parameters:
-
    -
  • dataset – Input dataset containing crimes and study area

  • -
  • y – Ignored, present for scikit-learn compatibility

  • -
-
-
Returns:
-

The fitted instance

-
-
Return type:
-

self

-
-
Raises:
-

Exception – If fitting fails

-
-
-
- -
-
-property grid
-

Spatial grid used for mapping

-
-
Type:
-

GeoDataFrame

-
-
-
- -
-
-predict()[source]
-

Make predictions for the next time step.

-
-
Returns:
-

Predictions for next time step

-
-
Return type:
-

pandas.DataFrame

-
-
-
- -
-
-set_fit_request(*, dataset: bool | None | str = '$UNCHANGED$') → PredictionPipeline
-

Request metadata passed to the fit method.

-

Note that this method is only relevant if -enable_metadata_routing=True (see sklearn.set_config()). -Please see User Guide on how the routing -mechanism works.

-

The options for each parameter are:

-
    -
  • True: metadata is requested, and passed to fit if provided. The request is ignored if metadata is not provided.

  • -
  • False: metadata is not requested and the meta-estimator will not pass it to fit.

  • -
  • None: metadata is not requested, and the meta-estimator will raise an error if the user provides it.

  • -
  • str: metadata should be passed to the meta-estimator with this given alias instead of the original name.

  • -
-

The default (sklearn.utils.metadata_routing.UNCHANGED) retains the -existing request. This allows you to change the request for some -parameters and not others.

-
-

Added in version 1.3.

-
-
-

Note

-

This method is only relevant if this estimator is used as a -sub-estimator of a meta-estimator, e.g. used inside a -Pipeline. Otherwise it has no effect.

-
-
-
Parameters:
-

dataset (str, True, False, or None, default=sklearn.utils.metadata_routing.UNCHANGED) – Metadata routing for dataset parameter in fit.

-
-
Returns:
-

self – The updated object.

-
-
Return type:
-

object

-
-
-
- -
-
-set_score_request(*, sample_weight: bool | None | str = '$UNCHANGED$') → PredictionPipeline
-

Request metadata passed to the score method.

-

Note that this method is only relevant if -enable_metadata_routing=True (see sklearn.set_config()). -Please see User Guide on how the routing -mechanism works.

-

The options for each parameter are:

-
    -
  • True: metadata is requested, and passed to score if provided. The request is ignored if metadata is not provided.

  • -
  • False: metadata is not requested and the meta-estimator will not pass it to score.

  • -
  • None: metadata is not requested, and the meta-estimator will raise an error if the user provides it.

  • -
  • str: metadata should be passed to the meta-estimator with this given alias instead of the original name.

  • -
-

The default (sklearn.utils.metadata_routing.UNCHANGED) retains the -existing request. This allows you to change the request for some -parameters and not others.

-
-

Added in version 1.3.

-
-
-

Note

-

This method is only relevant if this estimator is used as a -sub-estimator of a meta-estimator, e.g. used inside a -Pipeline. Otherwise it has no effect.

-
-
-
Parameters:
-

sample_weight (str, True, False, or None, default=sklearn.utils.metadata_routing.UNCHANGED) – Metadata routing for sample_weight parameter in score.

-
-
Returns:
-

self – The updated object.

-
-
Return type:
-

object

-
-
-
- -
-
-property stseries
-

Spatio-temporal series

-
-
Type:
-

pandas.Series

-
-
-
- -
- -
- - -
- - -
-
-
-
- - - - - - - \ No newline at end of file diff --git a/docs/build/html/modules/pipeline.html b/docs/build/html/modules/pipeline.html deleted file mode 100644 index 3899598..0000000 --- a/docs/build/html/modules/pipeline.html +++ /dev/null @@ -1,240 +0,0 @@ - - - - - - - - Pipeline — Predspot 1.0 documentation - - - - - - - - - - - - - - - - - - -
- - -
-
- - - -
- -
-

Pipeline

-

Pipeline Module

-

This module provides the main pipeline functionality for crime prediction, -including data loading, preprocessing, and model execution.

-

Example

-
>>> from predspot.pipeline import generate_testdata, run_prediction_pipeline
->>> crime_data, study_area = generate_testdata(10000, '2020-01-01', '2020-12-31')
->>> results = run_prediction_pipeline(crime_data, study_area)
-
-
-
-
-predspot.pipeline.evaluate_pipeline(pipeline, scoring='r2', cv=5, debug=False)[source]
-

Evaluate the prediction pipeline using cross-validation.

-
-
Parameters:
-
    -
  • pipeline (PredictionPipeline) – Fitted prediction pipeline

  • -
  • scoring (str, optional) – Scoring metric (‘r2’ or ‘mse’). Defaults to ‘r2’

  • -
  • cv (int, optional) – Number of cross-validation folds. Defaults to 5

  • -
  • debug (bool, optional) – Enable debug printing. Defaults to False

  • -
-
-
Returns:
-

Cross-validation scores

-
-
Return type:
-

list

-
-
-

Example

-
>>> scores = evaluate_pipeline(fitted_pipeline, scoring='r2', cv=5)
-
-
-
- -
-
-predspot.pipeline.generate_testdata(n_points, start_time, end_time, debug=False)[source]
-

Generate synthetic crime data for testing.

-
-
Parameters:
-
    -
  • n_points (int) – Number of crime incidents to generate

  • -
  • start_time (str) – Start date in ‘YYYY-MM-DD’ format

  • -
  • end_time (str) – End date in ‘YYYY-MM-DD’ format

  • -
  • debug (bool, optional) – Enable debug printing. Defaults to False

  • -
-
-
Returns:
-

(crimes_df, study_area_gdf) - Generated crime data and study area

-
-
Return type:
-

tuple

-
-
-

Example

-
>>> crimes, area = generate_testdata(1000, '2020-01-01', '2020-12-31')
-
-
-
- -
-
-predspot.pipeline.run_prediction_pipeline(crime_data, study_area, crime_tags=None, time_range=None, tfreq='M', grid_resolution=250, debug=False)[source]
-

Run the complete crime prediction pipeline.

-
-
Parameters:
-
    -
  • crime_data (pandas.DataFrame) – Crime incident data

  • -
  • study_area (geopandas.GeoDataFrame) – Study area boundaries

  • -
  • crime_tags (list, optional) – List of crime types to include

  • -
  • time_range (list, optional) – Time range as [‘HH:MM’, ‘HH:MM’]

  • -
  • tfreq (str, optional) – Time frequency (‘M’, ‘W’, ‘D’). Defaults to ‘M’

  • -
  • grid_resolution (float, optional) – Spatial grid resolution in km. Defaults to 250

  • -
  • debug (bool, optional) – Enable debug printing. Defaults to False

  • -
-
-
Returns:
-

(predictions, pipeline) - Predicted crime densities and fitted pipeline

-
-
Return type:
-

tuple

-
-
Raises:
-

ValueError – If input data is invalid or missing required columns

-
-
-
- -
- - -
- - -
-
-
-
- - - - - - - \ No newline at end of file diff --git a/docs/build/html/modules/utilities.html b/docs/build/html/modules/utilities.html deleted file mode 100644 index a5e4d8b..0000000 --- a/docs/build/html/modules/utilities.html +++ /dev/null @@ -1,302 +0,0 @@ - - - - - - - - Utilities — Predspot 1.0 documentation - - - - - - - - - - - - - - - - - - - -
- - -
-
- - - -
- -
-

Utilities

-

Utilities Module

-

This module provides utility functions and classes for data processing and visualization, -including contour generation, feature union operations, and pandas-specific transformations.

-
-
-class predspot.utilities.PandasFeatureUnion(transformer_list, n_jobs=None, transformer_weights=None, debug=False)[source]
-

Bases: FeatureUnion

-

A FeatureUnion transformer that preserves pandas DataFrames.

-

This class extends sklearn’s FeatureUnion to work with pandas DataFrames, -maintaining index alignment and column names.

-
-
-n_jobs
-

Number of parallel jobs

-
-
Type:
-

int

-
-
-
- -
-
-transformer_list
-

List of transformer tuples

-
-
Type:
-

list

-
-
-
- -
-
-transformer_weights
-

Weights for transformers

-
-
Type:
-

dict

-
-
-
- -
-
-debug
-

Enable debug printing

-
-
Type:
-

bool

-
-
-
- -
-
-fit_transform(X, y=None, **fit_params)[source]
-

Fit all transformers and transform the data.

-
-
Parameters:
-
    -
  • X (pandas.DataFrame) – Input features

  • -
  • y (array-like, optional) – Target values

  • -
  • **fit_params – Additional fitting parameters

  • -
-
-
Returns:
-

Transformed features

-
-
Return type:
-

pandas.DataFrame

-
-
Raises:
-

ValueError – If no transformers are provided

-
-
-
- -
-
-merge_dataframes_by_column(Xs)[source]
-

Merge transformed features into a single DataFrame.

-
-
Parameters:
-

Xs (list) – List of transformed DataFrames

-
-
Returns:
-

Merged DataFrame

-
-
Return type:
-

pandas.DataFrame

-
-
-
- -
-
-transform(X)[source]
-

Transform X separately by each transformer.

-
-
Parameters:
-

X (pandas.DataFrame) – Input features

-
-
Returns:
-

Transformed features

-
-
Return type:
-

pandas.DataFrame

-
-
-
- -
- -
-
-predspot.utilities.contour_geojson(y, bbox, resolution, cmin, cmax, debug=False)[source]
-

Generate GeoJSON contours from spatial data.

-
-
Parameters:
-
    -
  • y (pandas.Series) – Values to contour

  • -
  • bbox (GeoDataFrame) – Bounding box for the contour

  • -
  • resolution (float) – Spatial resolution in kilometers

  • -
  • cmin (float) – Minimum contour value

  • -
  • cmax (float) – Maximum contour value

  • -
  • debug (bool, optional) – Enable debug printing. Defaults to False

  • -
-
-
Returns:
-

GeoJSON representation of the contours

-
-
Return type:
-

dict

-
-
Raises:
-

AssertionError – If bbox is not a GeoDataFrame

-
-
-
- -
- - -
- - -
-
-
-
- - - - - - - \ No newline at end of file diff --git a/docs/build/html/objects.inv b/docs/build/html/objects.inv deleted file mode 100644 index 05c3ddc..0000000 Binary files a/docs/build/html/objects.inv and /dev/null differ diff --git a/docs/build/html/search.html b/docs/build/html/search.html deleted file mode 100644 index 5596f21..0000000 --- a/docs/build/html/search.html +++ /dev/null @@ -1,158 +0,0 @@ - - - - - - - Search — Predspot 1.0 documentation - - - - - - - - - - - - - - - - - - - - - - - - -
- - -
-
- - - -
- -

Search

- - - - -

- Searching for multiple words only shows matches that contain - all words. -

- - -
- - - -
- - -
- - -
- - -
-
-
-
- - - - - - - \ No newline at end of file diff --git a/docs/build/html/searchindex.js b/docs/build/html/searchindex.js deleted file mode 100644 index 67f14bb..0000000 --- a/docs/build/html/searchindex.js +++ /dev/null @@ -1 +0,0 @@ -Search.setIndex({"alltitles": {"Citation": [[0, "citation"]], "Contributing": [[0, "contributing"]], "Crime Mapping": [[1, null]], "Dataset Preparation": [[2, null]], "Documentation:": [[0, null]], "Feature Engineering": [[3, null]], "Important Notice": [[0, "important-notice"]], "Indices and tables": [[0, "indices-and-tables"]], "Input Data Format": [[0, "input-data-format"]], "Installation": [[0, "installation"]], "Key Features": [[0, "key-features"]], "License": [[0, "license"]], "ML Modelling": [[4, null]], "Modules": [[0, "modules"]], "Overview": [[0, "overview"]], "Pipeline": [[5, null]], "Predspot": [[0, null]], "Quick Start": [[0, "quick-start"]], "Resources": [[0, "resources"]], "Utilities": [[6, null]]}, "docnames": ["index", "modules/crime_mapping", "modules/dataset_preparation", "modules/feature_engineering", "modules/ml_modelling", "modules/pipeline", "modules/utilities"], "envversion": {"sphinx": 64, "sphinx.domains.c": 3, "sphinx.domains.changeset": 1, "sphinx.domains.citation": 1, "sphinx.domains.cpp": 9, "sphinx.domains.index": 1, "sphinx.domains.javascript": 3, "sphinx.domains.math": 2, "sphinx.domains.python": 4, "sphinx.domains.rst": 2, "sphinx.domains.std": 2, "sphinx.ext.intersphinx": 1, "sphinx.ext.todo": 2, "sphinx.ext.viewcode": 1}, "filenames": ["index.rst", "modules\\crime_mapping.rst", "modules\\dataset_preparation.rst", "modules\\feature_engineering.rst", "modules\\ml_modelling.rst", "modules\\pipeline.rst", "modules\\utilities.rst"], "indexentries": {}, "objects": {}, "objnames": {}, "objtypes": {}, "terms": {"": [0, 6], "0": [0, 1, 2], "01": 5, "1": [1, 2, 3, 4], "10": 0, "1000": [2, 5], "10000": 5, "12": [0, 5], "1256": 0, "1263": 0, "16th": 0, "2": 0, "2018": 0, "2019": 0, "2020": 5, "20th": 0, "25": 2, "250": [0, 5], "3": [0, 1, 3, 4], "31": 5, "4th": 0, "5": [4, 5], "8": 0, "9": 1, "A": [0, 2, 6], "And": 0, "For": 0, "If": [0, 1, 2, 3, 4, 5, 6], "In": 0, "It": [0, 1, 2], "The": [0, 1, 2, 3, 4], "To": 0, "abc": 1, "about": 0, "abstract": [1, 3], "across": 0, "activ": 0, "ad": [1, 3, 4], "adapt": 0, "addit": [1, 2, 6], "alia": [1, 3, 4], "align": [3, 6], "aligned_t": 3, "all": [0, 6], "allow": [1, 3, 4], "along": 2, "an": [0, 1, 3, 4], "analysi": [0, 1], "anyon": 0, "appli": 3, "apply_ts_decomposit": 3, "approach": 0, "ar": [0, 1, 3, 4, 6], "araujo": 0, "ara\u00fajo": 0, "archiv": 0, "area": [0, 2, 4, 5], "argument": 2, "arrai": [1, 6], "as_df": 1, "assertionerror": [2, 3, 6], "associ": 2, "autoregress": 3, "ax": 2, "bandwidth": 1, "bare": 0, "base": [0, 1, 2, 3, 4, 6], "baseestim": [1, 3, 4], "basic": 0, "bbox": [1, 6], "been": 4, "being": 4, "believ": 0, "between": [1, 2], "bezerra": 0, "bool": [1, 2, 3, 4, 5, 6], "borg": 0, "bound": [1, 6], "boundari": [0, 1, 2, 5], "box": [1, 6], "branch": 0, "brazil": 0, "bsd": 0, "build": 0, "c": 0, "cacho": 0, "can": 0, "cell": 1, "center": 1, "chang": [0, 1, 3, 4], "chunk": 1, "cite": 0, "citi": 0, "city_shap": 1, "class": [1, 2, 3, 4, 6], "claus": 0, "clean": 0, "cmax": 6, "cmin": 6, "code": 0, "codebas": 0, "collabor": 0, "column": [0, 2, 4, 5, 6], "combin": 0, "commit": 0, "commun": 0, "compat": [0, 3, 4], "complet": [4, 5], "compon": [2, 3], "comput": 0, "concept": 0, "conda": 0, "confer": 0, "consist": 0, "construct": 1, "contain": [2, 4], "contour": 6, "contour_geojson": 6, "coordin": 1, "core": 0, "correspond": [1, 2], "creat": [0, 1, 3], "create_gridhexagon": 1, "create_gridpoint": [0, 1], "create_gridsquar": 1, "create_hexagon": 1, "creation": 3, "crime": [0, 2, 4, 5], "crime_data": 5, "crime_dens": 4, "crime_map": [0, 1], "crime_sampl": 2, "crime_tag": 5, "crimes_df": [0, 5], "cross": [4, 5], "current": [0, 4], "cv": [4, 5], "d": [1, 3, 5], "daili": [1, 3], "data": [2, 5, 6], "data_point": 1, "datafram": [0, 1, 2, 3, 4, 5, 6], "dataset": [0, 4], "dataset_prepar": 2, "date": 5, "datetim": 1, "dd": 5, "debug": [1, 2, 3, 4, 5, 6], "decomposit": [0, 3], "default": [1, 2, 3, 4, 5, 6], "defin": [0, 2], "densiti": [0, 1, 4, 5], "depend": 0, "descart": 0, "detect": [0, 1], "develop": 0, "dict": [1, 2, 6], "dictionari": 2, "diff": [0, 3], "differ": [0, 3], "differenc": 3, "do": 0, "doesn": 4, "doesnt": 0, "dss": 0, "e": [1, 3, 4], "each": [1, 3, 4, 6], "earli": 0, "effect": [1, 3, 4], "effort": 0, "enabl": [1, 2, 3, 4, 5, 6], "enable_metadata_rout": [1, 3, 4], "encount": 0, "end": [1, 5], "end_tim": [1, 5], "engin": 0, "env": 0, "error": [1, 3, 4], "estim": [0, 1, 3, 4], "evalu": [0, 4, 5], "evaluate_pipelin": 5, "exampl": [0, 5], "except": 4, "execut": 5, "exist": [0, 1, 3, 4], "extend": 6, "extract": [3, 4], "fail": 4, "fals": [1, 2, 3, 4, 5, 6], "featur": [4, 6], "feature_engin": [0, 3], "feature_import": 4, "featuresc": 3, "featureselect": 4, "featureunion": 6, "feder": 0, "feel": 0, "fextract": [0, 4], "filter_place_ratio": 1, "fit": [0, 1, 4, 5, 6], "fit_grid": 1, "fit_param": 6, "fit_transform": 6, "fitted_pipelin": 5, "float": [1, 2, 5, 6], "fold": [4, 5], "follow": 0, "forecast": 4, "fork": 0, "form": 1, "format": 5, "four": 0, "framework": 0, "free": 0, "frequenc": [1, 3, 5], "from": [0, 3, 5, 6], "function": [0, 1, 2, 4, 5, 6], "g": [1, 3, 4], "gener": [5, 6], "generate_testdata": 5, "geodatafram": [0, 1, 2, 4, 5, 6], "geograph": 0, "geojson": 6, "geojsoncontour": 0, "geometri": [1, 2], "geopanda": [0, 2, 5], "get": [2, 4], "get_time_data_chunk": 1, "get_times_no_data": 1, "given": [1, 3, 4], "good": 0, "grand": 0, "grid": [0, 1, 4, 5], "grid_resolut": 5, "guarante": 0, "guid": [1, 3, 4], "guidelin": 0, "ha": [1, 3, 4], "handl": 2, "hasn": 4, "have": 0, "help": 0, "here": 0, "hexagon": 1, "hh": 5, "high": 0, "hotspot": [0, 1], "how": [1, 3, 4], "howev": 0, "hpcc": 0, "i": [0, 1, 2, 3, 4, 5, 6], "ident": 3, "identifi": 3, "ieee": 0, "ignor": [1, 3, 4], "implement": [0, 1, 3], "import": [4, 5], "incid": [1, 2, 5], "includ": [0, 1, 2, 3, 5, 6], "index": [0, 3, 6], "inform": 0, "input": [3, 4, 5, 6], "insid": [1, 3, 4], "instanc": 4, "instead": [1, 3, 4], "int": [2, 3, 4, 5, 6], "integ": 3, "interest": 0, "intern": 0, "invalid": [3, 4, 5], "issu": 0, "j": 0, "job": 6, "june": 0, "k": 1, "kde": [0, 1], "kernel": [0, 1], "keyword": 2, "kgrid": 1, "kilomet": [1, 6], "km": 5, "kwarg": 2, "l": [0, 1], "label": 3, "lag": [0, 3], "lag_df": 3, "lat": [0, 2], "latitud": [0, 2], "learn": [0, 3, 4], "length": [1, 3], "less": 3, "librari": 0, "like": 6, "list": [4, 5, 6], "load": [0, 5], "lon": [0, 2], "longitud": [0, 2], "m": [0, 1, 3, 5], "machin": [0, 4], "mai": 0, "main": [0, 5], "maintain": [0, 6], "make": 4, "make_lag_df": 3, "manag": [0, 2], "map": [0, 4], "master": 0, "matplotlib": [0, 2], "maximum": 6, "mechan": [1, 3, 4], "merg": 6, "merge_dataframes_by_column": 6, "meta": [1, 3, 4], "metadata": [1, 3, 4], "metadata_rout": [1, 3, 4], "method": [0, 1, 3, 4], "methodologi": 0, "metric": [4, 5], "might": 0, "minimum": 6, "miss": 5, "ml": 0, "ml_model": 4, "mm": 5, "model": [0, 5], "modern": 0, "modifi": 0, "modul": [1, 2, 3, 4, 5, 6], "monthli": [1, 3], "more": 0, "mse": [4, 5], "multi": 3, "must": 2, "n": 0, "n_job": 6, "n_point": 5, "name": [1, 3, 4, 6], "natal": 0, "need": 0, "new": 0, "newer": 0, "next": 4, "none": [1, 2, 3, 4, 5, 6], "nort": 0, "note": [0, 1, 3, 4], "number": [2, 3, 4, 5, 6], "numer": 1, "numpi": 0, "object": [1, 2, 3, 4], "onli": [1, 3, 4], "open": 0, "oper": 6, "option": [1, 2, 3, 4, 5, 6], "origin": [1, 3, 4], "other": [1, 3, 4], "otherwis": [1, 3, 4], "outdat": 0, "own": 0, "packag": 0, "page": 0, "panda": [0, 2, 3, 4, 5, 6], "pandasfeatureunion": [0, 6], "parallel": 6, "paramet": [1, 2, 3, 4, 5, 6], "part": 0, "pass": [1, 3, 4], "patrol": 0, "pattern": 0, "perform": [0, 4], "pip": 0, "pipelin": [0, 1, 3, 4], "place": 3, "plan": 0, "pleas": [0, 1, 3, 4], "plot": 2, "point": [0, 1], "polygon": 1, "posit": 3, "pp": 0, "pr": 0, "predict": [0, 4, 5], "predictionpipelin": [0, 4, 5], "predspot": [1, 2, 3, 4, 5, 6], "prefer": 0, "prepar": 0, "preprocess": 5, "present": 4, "preserv": 6, "print": [1, 2, 3, 4, 5, 6], "process": [2, 6], "product": 0, "project": 0, "properli": 0, "properti": [2, 3, 4], "proport": 2, "provid": [1, 2, 3, 4, 5, 6], "pull": 0, "purpos": 0, "push": 0, "python": 0, "quadratcount": 1, "quadratcount2": 1, "r2": [4, 5], "rais": [1, 2, 3, 4, 5, 6], "rang": 5, "re": 0, "readi": 0, "refer": 0, "regress": 4, "regressormixin": 4, "relev": [1, 3, 4], "repositori": 0, "represent": 6, "request": [0, 1, 3, 4], "requir": [0, 2, 5], "research": 0, "resolut": [0, 1, 5, 6], "result": [0, 1, 5], "retain": [1, 3, 4], "return": [1, 2, 3, 4, 5, 6], "return_coord": 1, "reviv": 0, "rio": 0, "rout": [1, 3, 4], "rtree": 0, "run": 5, "run_prediction_pipelin": 5, "sampl": 2, "sample_weight": 4, "scale": 3, "scienc": 0, "scikit": [0, 3, 4], "scipi": 0, "score": [4, 5], "search": 0, "season": [0, 3], "see": [1, 3, 4], "select": 4, "selector": 4, "self": [1, 3, 4], "separ": 6, "seri": [0, 3, 4, 6], "set": 2, "set_config": [1, 3, 4], "set_fit_request": [1, 4], "set_predict_request": 4, "set_score_request": 4, "set_transform_request": [1, 3, 4], "shape": 2, "should": [0, 1, 3, 4], "side": 1, "silverman": 1, "singl": 6, "size": 1, "sklearn": [1, 3, 4, 6], "smart": 0, "smartciti": 0, "so": 0, "softwar": 0, "some": [0, 1, 3, 4], "sourc": [1, 2, 3, 4, 5, 6], "space": [0, 1], "spatial": [0, 1, 2, 4, 5, 6], "spatio": [0, 1, 4], "spatiotemporalmap": 1, "specif": [0, 6], "split": 2, "squar": 1, "start": [1, 5], "start_tim": [1, 5], "state": 0, "statsmodel": 0, "step": 4, "still": 0, "stldecompos": 0, "str": [1, 3, 4, 5], "stseri": [3, 4], "studi": [0, 2, 4, 5], "study_area": [0, 2, 5], "study_area_gdf": [0, 5], "sub": [1, 3, 4], "submit": 0, "support": 4, "synthet": 5, "system": 0, "t": [0, 2, 3, 4], "tag": [0, 2], "target": [4, 6], "techniqu": 0, "tempor": [0, 1, 4], "test": [2, 5], "test_dataset": 2, "test_siz": 2, "tfreq": [0, 1, 3, 5], "than": 3, "thesi": 0, "thi": [0, 1, 2, 3, 4, 5, 6], "time": [0, 1, 3, 4, 5], "time_rang": 5, "timeseriesfeatur": 3, "timestamp": [0, 2], "tool": 0, "toward": 0, "train": 2, "train_dataset": 2, "train_test_split": 2, "transform": [1, 3, 4, 6], "transformer_list": 6, "transformer_weight": 6, "transformermixin": [1, 3, 4], "trend": [0, 3], "true": [1, 3, 4], "tupl": [1, 2, 3, 5, 6], "two": [0, 2], "type": [0, 1, 2, 3, 4, 5, 6], "u": 0, "ufrn": 0, "unchang": [1, 3, 4], "union": 6, "universidad": 0, "updat": [1, 3, 4], "upon": 0, "us": [0, 1, 3, 4, 5], "usag": 0, "user": [1, 3, 4], "util": [0, 1, 3, 4], "valid": [4, 5], "valu": [1, 6], "valuabl": 0, "valueerror": [5, 6], "variabl": 4, "version": [0, 1, 3, 4], "vieira": 0, "visual": [0, 2, 6], "w": [1, 3, 5], "wa": 0, "want": 0, "we": 0, "weekli": [1, 3], "weight": 6, "welcom": 0, "which": 1, "while": 0, "within": 1, "work": [0, 1, 3, 4, 6], "wrapper": 4, "x": [1, 3, 4, 6], "y": [0, 1, 4, 6], "you": [0, 1, 3, 4], "your": 0, "your_favorite_sklearn_model": 0, "yyyi": 5}, "titles": ["Predspot", "Crime Mapping", "Dataset Preparation", "Feature Engineering", "ML Modelling", "Pipeline", "Utilities"], "titleterms": {"citat": 0, "contribut": 0, "crime": 1, "data": 0, "dataset": 2, "document": 0, "engin": 3, "featur": [0, 3], "format": 0, "import": 0, "indic": 0, "input": 0, "instal": 0, "kei": 0, "licens": 0, "map": 1, "ml": 4, "model": 4, "modul": 0, "notic": 0, "overview": 0, "pipelin": 5, "predspot": 0, "prepar": 2, "quick": 0, "resourc": 0, "start": 0, "tabl": 0, "util": 6}}) \ No newline at end of file diff --git a/docs/changelog.md b/docs/changelog.md new file mode 100644 index 0000000..786b75d --- /dev/null +++ b/docs/changelog.md @@ -0,0 +1 @@ +--8<-- "CHANGELOG.md" diff --git a/docs/citing.md b/docs/citing.md new file mode 100644 index 0000000..666d07e --- /dev/null +++ b/docs/citing.md @@ -0,0 +1,43 @@ +# Citing Predspot + +If you use Predspot in your research, please cite the thesis and the paper +that introduced the framework: + +> Araujo, A., & Cacho, N. (2019). *Predspot: Predicting crime hotspots with +> machine learning*. Master's thesis, Universidade Federal do Rio Grande do +> Norte (UFRN), Natal, Brazil. + +> Araújo, A., Cacho, N., Bezerra, L., Vieira, C., & Borges, J. (2018). Towards +> a crime hotspot detection framework for patrol planning. In *2018 IEEE 20th +> International Conference on High Performance Computing and Communications; +> IEEE 16th International Conference on Smart City; IEEE 4th International +> Conference on Data Science and Systems (HPCC/SmartCity/DSS)* (pp. 1256-1263). +> IEEE. + +```bibtex +@mastersthesis{araujo2019predspot, + title = {Predspot: Predicting crime hotspots with machine learning}, + author = {Araujo, Adelson}, + year = {2019}, + school = {Universidade Federal do Rio Grande do Norte} +} + +@inproceedings{araujo2018towards, + title = {Towards a crime hotspot detection framework for patrol planning}, + author = {Ara{\'u}jo, Adelson and Cacho, N{\'a}dia and Bezerra, Lucas and Vieira, Carlos and Borges, Jo{\~a}o}, + booktitle = {2018 IEEE 20th International Conference on High Performance Computing and Communications; IEEE 16th International Conference on Smart City; IEEE 4th International Conference on Data Science and Systems (HPCC/SmartCity/DSS)}, + pages = {1256--1263}, + year = {2018}, + organization = {IEEE} +} +``` + +## Further reading + +- Chainey, S., Tompson, L., & Uhlig, S. (2008). The utility of hotspot mapping + for predicting spatial patterns of crime. *Security Journal*, 21, 4-28. +- Cleveland, R. B., Cleveland, W. S., McRae, J. E., & Terpenning, I. (1990). + STL: A seasonal-trend decomposition procedure based on loess. *Journal of + Official Statistics*, 6(1), 3-73. +- Silverman, B. W. (1986). *Density Estimation for Statistics and Data + Analysis*. Chapman & Hall. diff --git a/docs/contributing.md b/docs/contributing.md new file mode 100644 index 0000000..ea38c9b --- /dev/null +++ b/docs/contributing.md @@ -0,0 +1 @@ +--8<-- "CONTRIBUTING.md" diff --git a/docs/error_message b/docs/error_message deleted file mode 100644 index 0acf455..0000000 --- a/docs/error_message +++ /dev/null @@ -1,57 +0,0 @@ -Running Sphinx v8.1.3 -loading translations [en]... done -making output directory... done -building [mo]: targets for 0 po files that are out of date -writing output... -building [html]: targets for 7 source files that are out of date -updating environment: [new config] 7 added, 0 changed, 0 removed -reading sources... [100%] modules/utilities -looking for now-outdated files... none found -pickling environment... done -checking consistency... C:\Users\DiasDeAraujoJunioA\Documents\Repositories\predspot\docs\source\modules\crime_mapping.rst: WARNING: document isn't included in any toctree -C:\Users\DiasDeAraujoJunioA\Documents\Repositories\predspot\docs\source\modules\dataset_preparation.rst: WARNING: document isn't included in any toctree -C:\Users\DiasDeAraujoJunioA\Documents\Repositories\predspot\docs\source\modules\feature_engineering.rst: WARNING: document isn't included in any toctree -C:\Users\DiasDeAraujoJunioA\Documents\Repositories\predspot\docs\source\modules\ml_modelling.rst: WARNING: document isn't included in any toctree -C:\Users\DiasDeAraujoJunioA\Documents\Repositories\predspot\docs\source\modules\pipeline.rst: WARNING: document isn't included in any toctree -C:\Users\DiasDeAraujoJunioA\Documents\Repositories\predspot\docs\source\modules\utilities.rst: WARNING: document isn't included in any toctree -done -preparing documents... done -copying assets... -copying static files... -Writing evaluated template result to C:\Users\DiasDeAraujoJunioA\Documents\Repositories\predspot\docs\build\html\_static\basic.css -Writing evaluated template result to C:\Users\DiasDeAraujoJunioA\Documents\Repositories\predspot\docs\build\html\_static\documentation_options.js -Writing evaluated template result to C:\Users\DiasDeAraujoJunioA\Documents\Repositories\predspot\docs\build\html\_static\language_data.js -Writing evaluated template result to C:\Users\DiasDeAraujoJunioA\Documents\Repositories\predspot\docs\build\html\_static\alabaster.css -copying static files: done -copying extra files... -copying extra files: done -copying assets: done -writing output... [100%] modules/utilities -C:\Users\DiasDeAraujoJunioA\Documents\Repositories\predspot\predspot\crime_mapping.py:docstring of sklearn.utils._metadata_requests.RequestMethod.__get__..func:3: WARNING: undefined label: 'metadata_routing' [ref.ref] -C:\Users\DiasDeAraujoJunioA\Documents\Repositories\predspot\predspot\crime_mapping.py:docstring of sklearn.utils._metadata_requests.RequestMethod.__get__..func:3: WARNING: undefined label: 'metadata_routing' [ref.ref] -C:\Users\DiasDeAraujoJunioA\Documents\Repositories\predspot\predspot\crime_mapping.py:docstring of sklearn.utils._metadata_requests.RequestMethod.__get__..func:3: WARNING: undefined label: 'metadata_routing' [ref.ref] -C:\Users\DiasDeAraujoJunioA\Documents\Repositories\predspot\predspot\crime_mapping.py:docstring of sklearn.utils._metadata_requests.RequestMethod.__get__..func:3: WARNING: undefined label: 'metadata_routing' [ref.ref] -C:\Users\DiasDeAraujoJunioA\Documents\Repositories\predspot\predspot\crime_mapping.py:docstring of sklearn.utils._metadata_requests.RequestMethod.__get__..func:3: WARNING: undefined label: 'metadata_routing' [ref.ref] -C:\Users\DiasDeAraujoJunioA\Documents\Repositories\predspot\predspot\crime_mapping.py:docstring of sklearn.utils._metadata_requests.RequestMethod.__get__..func:3: WARNING: undefined label: 'metadata_routing' [ref.ref] -C:\Users\DiasDeAraujoJunioA\Documents\Repositories\predspot\predspot\crime_mapping.py:docstring of sklearn.utils._metadata_requests.RequestMethod.__get__..func:3: WARNING: undefined label: 'metadata_routing' [ref.ref] -C:\Users\DiasDeAraujoJunioA\Documents\Repositories\predspot\predspot\feature_engineering.py:docstring of sklearn.utils._metadata_requests.RequestMethod.__get__..func:3: WARNING: undefined label: 'metadata_routing' [ref.ref] -C:\Users\DiasDeAraujoJunioA\Documents\Repositories\predspot\predspot\feature_engineering.py:docstring of sklearn.utils._metadata_requests.RequestMethod.__get__..func:3: WARNING: undefined label: 'metadata_routing' [ref.ref] -C:\Users\DiasDeAraujoJunioA\Documents\Repositories\predspot\predspot\feature_engineering.py:docstring of sklearn.utils._metadata_requests.RequestMethod.__get__..func:3: WARNING: undefined label: 'metadata_routing' [ref.ref] -C:\Users\DiasDeAraujoJunioA\Documents\Repositories\predspot\predspot\feature_engineering.py:docstring of sklearn.utils._metadata_requests.RequestMethod.__get__..func:3: WARNING: undefined label: 'metadata_routing' [ref.ref] -C:\Users\DiasDeAraujoJunioA\Documents\Repositories\predspot\predspot\feature_engineering.py:docstring of sklearn.utils._metadata_requests.RequestMethod.__get__..func:3: WARNING: undefined label: 'metadata_routing' [ref.ref] -C:\Users\DiasDeAraujoJunioA\Documents\Repositories\predspot\predspot\feature_engineering.py:docstring of sklearn.utils._metadata_requests.RequestMethod.__get__..func:3: WARNING: undefined label: 'metadata_routing' [ref.ref] -C:\Users\DiasDeAraujoJunioA\Documents\Repositories\predspot\predspot\ml_modelling.py:docstring of sklearn.utils._metadata_requests.RequestMethod.__get__..func:3: WARNING: undefined label: 'metadata_routing' [ref.ref] -C:\Users\DiasDeAraujoJunioA\Documents\Repositories\predspot\predspot\ml_modelling.py:docstring of sklearn.utils._metadata_requests.RequestMethod.__get__..func:3: WARNING: undefined label: 'metadata_routing' [ref.ref] -C:\Users\DiasDeAraujoJunioA\Documents\Repositories\predspot\predspot\ml_modelling.py:docstring of sklearn.utils._metadata_requests.RequestMethod.__get__..func:3: WARNING: undefined label: 'metadata_routing' [ref.ref] -C:\Users\DiasDeAraujoJunioA\Documents\Repositories\predspot\predspot\ml_modelling.py:docstring of sklearn.utils._metadata_requests.RequestMethod.__get__..func:3: WARNING: undefined label: 'metadata_routing' [ref.ref] -C:\Users\DiasDeAraujoJunioA\Documents\Repositories\predspot\predspot\ml_modelling.py:docstring of sklearn.utils._metadata_requests.RequestMethod.__get__..func:3: WARNING: undefined label: 'metadata_routing' [ref.ref] -C:\Users\DiasDeAraujoJunioA\Documents\Repositories\predspot\predspot\ml_modelling.py:docstring of sklearn.utils._metadata_requests.RequestMethod.__get__..func:3: WARNING: undefined label: 'metadata_routing' [ref.ref] -C:\Users\DiasDeAraujoJunioA\Documents\Repositories\predspot\predspot\ml_modelling.py:docstring of sklearn.utils._metadata_requests.RequestMethod.__get__..func:3: WARNING: undefined label: 'metadata_routing' [ref.ref] -generating indices... genindex done -highlighting module code... [100%] predspot.utilities -writing additional pages... search done -dumping search index in English (code: en)... done -dumping object inventory... done -build succeeded, 26 warnings. - -The HTML pages are in build\html. \ No newline at end of file diff --git a/docs/examples/natal.ipynb b/docs/examples/natal.ipynb new file mode 120000 index 0000000..a40a4d7 --- /dev/null +++ b/docs/examples/natal.ipynb @@ -0,0 +1 @@ +../../examples/natal.ipynb \ No newline at end of file diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md new file mode 100644 index 0000000..ed42c27 --- /dev/null +++ b/docs/getting-started/installation.md @@ -0,0 +1,45 @@ +# Installation + +Predspot requires **Python 3.10 or newer** and is published on PyPI: + +```bash +pip install predspot +``` + +Optional extras add features that need heavier dependencies: + +| Extra | Installs | Enables | +|-------|----------|---------| +| `osm` | [osmnx](https://osmnx.readthedocs.io) | [`load_study_area`][predspot.crime_mapping.load_study_area] — study areas from OpenStreetMap | +| `contour` | [geojsoncontour](https://github.com/bartromgens/geojsoncontour) | [`contour_geojson`][predspot.utilities.contour_geojson] — GeoJSON contour export | +| `dev` | pytest, ruff, build, twine | Running the test suite and building the package | + +```bash +pip install "predspot[osm,contour]" +``` + +The core dependencies — pandas, GeoPandas, Shapely, NumPy, SciPy, scikit-learn, +statsmodels and Matplotlib — are installed automatically. + +!!! tip "conda users" + GeoPandas and its GEOS/PROJ stack install fine from PyPI wheels nowadays, + but if you prefer conda: `conda install -c conda-forge geopandas` first, + then `pip install predspot` in the same environment. + +## From source + +```bash +git clone https://github.com/adaj/predspot.git +cd predspot +pip install -e ".[dev,osm,contour]" +pytest +``` + +See [Contributing](../contributing.md) for the development workflow. + +## Checking the installation + +```python +import predspot +print(predspot.__version__) +``` diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md new file mode 100644 index 0000000..b9750eb --- /dev/null +++ b/docs/getting-started/quickstart.md @@ -0,0 +1,124 @@ +# Quickstart + +This walkthrough goes from nothing to a hotspot forecast in a few minutes, using +synthetic data so you can run it as is. Every step is explained in depth in the +[user guide](../guide/concepts.md). + +## 1. A study area + +Predspot needs the boundary of the region you are studying as a GeoDataFrame +with a CRS. The easiest way is to fetch it from OpenStreetMap +(`pip install "predspot[osm]"`): + +```python +from predspot import load_study_area + +study_area = load_study_area("Natal, Rio Grande do Norte, Brazil") +``` + +Any other source works too — a shapefile or GeoJSON read with +`geopandas.read_file`, or a simple box: + +```python +import geopandas as gpd +from shapely.geometry import box + +study_area = gpd.GeoDataFrame(geometry=[box(-35.30, -5.90, -35.20, -5.80)], crs="EPSG:4326") +``` + +## 2. Crime events + +Your data must be a pandas DataFrame with four columns: + +| Column | Meaning | +|--------|---------| +| `tag` | Crime type (any string) | +| `t` | Timestamp (anything `pandas.to_datetime` understands) | +| `lon`, `lat` | Coordinates in WGS84 degrees | + +No data at hand? Generate realistic synthetic events inside the study area: + +```python +from predspot import generate_crimes + +crimes = generate_crimes(study_area, n_events=5000, n_hotspots=4, + start="2019-01-01", end="2020-12-31", seed=0) +crimes.head() +``` + +Wrap both in a [`Dataset`][predspot.dataset_preparation.Dataset]: + +```python +from predspot import Dataset + +dataset = Dataset(crimes, study_area) +dataset.plot() # study area + a sample of events +``` + +
+ ![Synthetic dataset](../assets/synthetic_dataset.png){ width="480" } +
+ +## 3. Build and fit a pipeline + +A [`PredictionPipeline`][predspot.ml_modelling.PredictionPipeline] chains a +spatio-temporal **mapping**, a **feature extraction** step and a scikit-learn +**estimator**: + +```python +from sklearn.ensemble import RandomForestRegressor + +from predspot import PredictionPipeline, PandasFeatureUnion +from predspot.crime_mapping import KDE, create_gridpoints +from predspot.feature_engineering import Seasonality, Trend, Diff + +grid = create_gridpoints(study_area, resolution=1) # points every 1 km + +pipeline = PredictionPipeline( + mapping=KDE(tfreq="M", grid=grid), # monthly density per point + fextraction=PandasFeatureUnion([ + ("seasonal", Seasonality(lags=6)), + ("trend", Trend(lags=6)), + ("diff", Diff(lags=6)), + ]), + estimator=RandomForestRegressor(n_estimators=100, random_state=0), + random_state=0, +) +pipeline.fit(dataset) +``` + +[`build_default_pipeline`][predspot.pipeline.build_default_pipeline] builds +exactly this kind of pipeline (with scaling and feature selection) in one call. + +## 4. Evaluate and forecast + +```python +pipeline.evaluate("r2", cv=3) # one score per time series fold +forecast = pipeline.predict() # DataFrame indexed by (t, places) +``` + +`forecast` holds the predicted density for the month after the last observed +one, for every grid point. Join it with the grid to map it: + +```python +hot = pipeline.grid.join(forecast.droplevel("t")) +hot.plot(column="crime_density", cmap="magma", markersize=12, legend=True) +``` + +Calling `predict()` again forecasts the following month, and so on — each +forecast is appended to the series and the features are recomputed. + +## 5. Prefer counts on hexagons? + +Swap the mapping and the grid; everything else stays the same: + +```python +from predspot.crime_mapping import QuadratCount, create_gridhexagonal + +mapping = QuadratCount(tfreq="W", grid=create_gridhexagonal(study_area, resolution=1)) +``` + +
+ ![KDE vs QuadratCount](../assets/kde_vs_quadrat.png){ width="900" } +
The same month mapped with KDE on a point grid (left) and with event counts on a hexagonal grid (right).
+
diff --git a/docs/guide/concepts.md b/docs/guide/concepts.md new file mode 100644 index 0000000..5dc896f --- /dev/null +++ b/docs/guide/concepts.md @@ -0,0 +1,76 @@ +# How Predspot works + +Predspot frames hotspot prediction as a **supervised time series problem on a +spatial grid**. The library is organised around four stages that map onto four +modules: + +```mermaid +flowchart LR + A["Crime events
(tag, t, lon, lat)"] --> B["Dataset
dataset_preparation"] + S["Study area
(GeoDataFrame)"] --> B + B --> C["Spatio-temporal mapping
crime_mapping"] + C --> D["Feature engineering
feature_engineering"] + D --> E["Estimator
ml_modelling"] + E --> F["Forecast for
the next period"] +``` + +## 1. Dataset + +A [`Dataset`][predspot.dataset_preparation.Dataset] is a validated pair of +crime events (a DataFrame with `tag`, `t`, `lon`, `lat`) and a study area +(a GeoDataFrame with a CRS). The events become a GeoDataFrame of points in +WGS84. See [Data and study areas](data.md). + +## 2. Spatio-temporal mapping + +The study area is discretised into **places** — a grid of points, hexagons or +squares — and time into **periods** (`tfreq`: daily, weekly or monthly). A +mapping assigns a value to every `(period, place)` pair: + +- [`KDE`][predspot.crime_mapping.KDE] fits a Gaussian kernel density estimate + to the events of each period and evaluates it at the grid points (the + default, and the approach of the original thesis); +- [`QuadratCount`][predspot.crime_mapping.QuadratCount] counts the events + falling in each polygonal cell. + +Both return the same object: a `pandas.Series` named `crime_density` with a +`(t, places)` MultiIndex — the *spatio-temporal series*. See +[Grids and mapping](mapping.md). + +## 3. Feature engineering + +For every place, the history of the series is turned into lagged features. +Each feature class first transforms the series (identity, first difference, +STL seasonal or trend component) and then builds `lags` columns with the +previous values. A [`PandasFeatureUnion`][predspot.utilities.PandasFeatureUnion] +concatenates several of them. The result is a feature matrix indexed by +`(t, places)`, with one extra row per place for the period *after* the last +observed one — the row the model will forecast. See +[Feature engineering](features.md). + +## 4. Estimator + +Any scikit-learn regressor learns to map the features at `t` to the series +value at `t`. Because every row is a `(period, place)` pair, a single model +is shared by all places, and the spatial structure enters through the mapping +and the features rather than through the model. `PredictionPipeline` shuffles +the rows for training, evaluates with +[`TimeSeriesSplit`](https://scikit-learn.org/stable/modules/generated/sklearn.model_selection.TimeSeriesSplit.html) +and forecasts the next period. See [Prediction and evaluation](modelling.md). + +## Design choices worth knowing + +- **Resolutions are in kilometres.** Grid functions convert them to degrees + using the length of a degree at the equator, so cells are slightly + distorted away from it; for city-scale study areas this is negligible. +- **Grids are built in WGS84 and re-projected to the study area's CRS.** + Cell centroids are computed in a projected CRS, so `lon`/`lat` columns + are accurate. +- **KDE bandwidth is estimated once.** With `bandwidth="silverman"` (or + `"scott"`) the factor is estimated on the first period with at least three + events and held fixed, so densities are comparable across periods. Pass a + number to fix it yourself. +- **Periods with no events are kept** (zeros everywhere), and `start_time` / + `end_time` can extend the series beyond the observed range. +- **Monthly periods use pandas' `ME` (month end) frequency.** You can keep + writing `tfreq="M"`; it is translated internally. diff --git a/docs/guide/data.md b/docs/guide/data.md new file mode 100644 index 0000000..137fad6 --- /dev/null +++ b/docs/guide/data.md @@ -0,0 +1,111 @@ +# Data and study areas + +## Crime events + +Predspot works with *point events*: one row per crime with a type, a +timestamp and a location. The input is a plain pandas DataFrame with the +columns below; extra columns are kept and ignored. + +| Column | Type | Notes | +|--------|------|-------| +| `tag` | str | Crime type. Filter on it to model one type at a time. | +| `t` | datetime-like | Parsed with `pandas.to_datetime`. Timezone-naive local time is the simplest. | +| `lon` | float | Longitude in WGS84 degrees (EPSG:4326). | +| `lat` | float | Latitude in WGS84 degrees. | + +```python +import pandas as pd + +crimes = pd.read_csv("crimes.csv", parse_dates=["t"]) +crimes = crimes[crimes["tag"] == "robbery"] +``` + +## Study area + +The study area is a GeoDataFrame with one or more polygons and a CRS. It +bounds the grids and is used for plotting; events outside it are not +removed, but grid cells are only created where they intersect it. + +=== "From OpenStreetMap" + + ```python + from predspot import load_study_area + + study_area = load_study_area("Natal, Rio Grande do Norte, Brazil") + ``` + + [`load_study_area`][predspot.crime_mapping.load_study_area] geocodes the + place with [osmnx](https://osmnx.readthedocs.io) (Nominatim) and returns + its administrative boundary. Be specific — add the state and country — + so that the first match is the boundary you want; `which_result` lets you + pick another match. Requires `pip install "predspot[osm]"`. + +=== "From a file" + + ```python + import geopandas as gpd + + study_area = gpd.read_file("city_limits.geojson") # or .shp, .gpkg, ... + assert study_area.crs is not None + ``` + +=== "From a bounding box" + + ```python + import geopandas as gpd + from shapely.geometry import box + + study_area = gpd.GeoDataFrame( + geometry=[box(-35.30, -5.90, -35.20, -5.80)], crs="EPSG:4326") + ``` + +Any CRS is accepted; grids are re-projected to it. + +## The `Dataset` object + +```python +from predspot import Dataset + +dataset = Dataset(crimes, study_area) +dataset # summary with counts per tag +dataset.crimes # GeoDataFrame of points (WGS84), `t` parsed +dataset.study_area +dataset.plot(crime_samples=2000) +train, test = dataset.train_test_split(test_size=0.25, random_state=0) +``` + +`Dataset` validates the inputs and never modifies the DataFrame you pass in. + +## Synthetic data + +[`generate_crimes`][predspot.synthetic.generate_crimes] produces events with +the structure real crime data tends to have, inside any study area: + +- **space**: `n_hotspots` Gaussian clusters (centres drawn inside the area, + spread `hotspot_sd_km`) holding a `hotspot_share` of the events, plus a + uniform background; +- **time**: an intensity combining a linear `trend`, an annual cycle + (`annual_amplitude`, `annual_peak_month`), a `weekly_profile` (Monday to + Sunday) and an `hourly_profile` (0-23 h). Timestamps are sampled by + thinning, so the patterns are exact in expectation. + +```python +from predspot import generate_crimes + +crimes, hotspots = generate_crimes( + study_area, n_events=8000, n_hotspots=4, hotspot_sd_km=0.6, + start="2019-01-01", end="2020-12-31", + trend=0.3, annual_amplitude=0.3, annual_peak_month=12, + tags={"robbery": 0.6, "burglary": 0.4}, + seed=7, return_hotspots=True, +) +``` + +`hotspots` is a GeoDataFrame with the centre, spread and share of each +hotspot — handy to check what a model recovers. Set `n_hotspots=0` for a +uniform map, `weekly_profile=None` / `hourly_profile=None` to switch those +patterns off, and a `seed` for reproducibility. + +
+ ![Synthetic dataset](../assets/synthetic_dataset.png){ width="480" } +
diff --git a/docs/guide/features.md b/docs/guide/features.md new file mode 100644 index 0000000..cb1a56e --- /dev/null +++ b/docs/guide/features.md @@ -0,0 +1,100 @@ +# Feature engineering + +The spatio-temporal series is a panel: one time series per place. Feature +classes in [`predspot.feature_engineering`][predspot.feature_engineering] +turn each of those series into lagged predictors. + +## Feature classes + +Every class takes `lags` (how many previous periods become columns) and an +optional `tfreq` (inferred from the series when omitted): + +| Class | Transformation before lagging | Columns | +|-------|-------------------------------|---------| +| [`AR`][predspot.feature_engineering.AR] | none (raw values) | `ar_1 … ar_k` | +| [`Diff`][predspot.feature_engineering.Diff] | first difference | `diff_1 … diff_k` | +| [`Seasonality`][predspot.feature_engineering.Seasonality] | seasonal component of an STL decomposition with period `lags` | `seasonal_1 … seasonal_k` | +| [`Trend`][predspot.feature_engineering.Trend] | trend component of the same STL decomposition | `trend_1 … trend_k` | + +`ar_1` at period `t` is the value at `t-1`, `ar_2` the value at `t-2`, and so +on. For `Seasonality` and `Trend`, `lags` doubles as the STL period: use 12 +for monthly data with a yearly cycle, 7 for daily data with a weekly cycle, +52 for weekly data, and make sure the series is at least twice as long. + +```python +from predspot.feature_engineering import AR, Seasonality + +X = AR(lags=3).fit_transform(stseries) +X.head() +``` + +``` + ar_1 ar_2 ar_3 +t places +2019-04-30 0 14.83 14.02 12.31 + 1 ... +``` + +Note the index: features at `t` only use values **before** `t`, and the +matrix always includes one extra period after the last observed one — the +row a fitted model uses to forecast. + +## Combining features + +[`PandasFeatureUnion`][predspot.utilities.PandasFeatureUnion] runs several +feature transformers and concatenates their columns, aligning on the +`(t, places)` index and dropping rows that any transformer could not fill +(warm-up periods): + +```python +from predspot import PandasFeatureUnion +from predspot.feature_engineering import Seasonality, Trend, Diff + +fextraction = PandasFeatureUnion([ + ("seasonal", Seasonality(lags=12)), + ("trend", Trend(lags=12)), + ("diff", Diff(lags=12)), +]) +X = fextraction.fit_transform(stseries) +``` + +## Scaling and selection inside the estimator + +Because features are a DataFrame, plain scikit-learn transformers would drop +the index. [`FeatureScaling`][predspot.feature_engineering.FeatureScaling] +and [`FeatureSelection`][predspot.ml_modelling.FeatureSelection] wrap any +scaler / selector so that DataFrames come out the other side, and +[`Model`][predspot.ml_modelling.Model] does the same for the regressor: + +```python +from sklearn.pipeline import Pipeline +from sklearn.preprocessing import QuantileTransformer +from sklearn.feature_selection import RFE +from sklearn.ensemble import RandomForestRegressor +from predspot.feature_engineering import FeatureScaling +from predspot.ml_modelling import FeatureSelection, Model + +estimator = Pipeline([ + ("scaling", FeatureScaling(QuantileTransformer(n_quantiles=10))), + ("selection", FeatureSelection(RFE(RandomForestRegressor(n_estimators=20)))), + ("model", Model(RandomForestRegressor(n_estimators=100))), +]) +``` + +## Writing your own feature + +Subclass [`TimeSeriesFeatures`][predspot.feature_engineering.TimeSeriesFeatures], +set `label` and implement `apply_ts_decomposition(ts)`, which receives the +series of one place and returns the series to lag: + +```python +from predspot.feature_engineering import TimeSeriesFeatures + +class RollingMean(TimeSeriesFeatures): + @property + def label(self): + return "rmean" + + def apply_ts_decomposition(self, ts): + return ts.rolling(3, min_periods=1).mean() +``` diff --git a/docs/guide/mapping.md b/docs/guide/mapping.md new file mode 100644 index 0000000..66f4a10 --- /dev/null +++ b/docs/guide/mapping.md @@ -0,0 +1,105 @@ +# Grids and spatio-temporal mapping + +Mapping turns raw events into the **spatio-temporal series**: one value per +place and period. It is the step that defines what "hotspot" means in your +analysis. + +## Grids + +All grid functions take the study area and a `resolution` in **kilometres**, +and return a GeoDataFrame with `geometry`, `lon`, `lat` columns and an +index named `places`. Only cells intersecting the study area are kept. + +| Function | Cells | Pairs with | +|----------|-------|------------| +| [`create_gridpoints`][predspot.crime_mapping.create_gridpoints] | points spaced `resolution` km apart | `KDE` (default) | +| [`create_gridhexagonal`][predspot.crime_mapping.create_gridhexagonal] | hexagons with the area of a `resolution` km square | `QuadratCount` | +| [`create_gridsquares`][predspot.crime_mapping.create_gridsquares] | squares of side `resolution` km | `QuadratCount` | + +```python +from predspot.crime_mapping import create_gridpoints, create_gridhexagonal + +points = create_gridpoints(study_area, resolution=0.5) +hexes = create_gridhexagonal(study_area, resolution=1) +hexes.plot(edgecolor="white") +``` + +Choosing a resolution is a trade-off: finer grids give more spatial detail +but more places to model (and, for KDE, more points to evaluate); the +original work used 250 m to 1 km for city-scale studies. Hexagons are +preferable to squares for counts because every neighbour is at the same +distance. + +## Time frequency + +`tfreq` sets the period: `"D"` (daily), `"W"` (weekly, Sunday-ending) or +`"M"` (monthly, month-end labels). Periods without events are kept with +zeros, and `start_time` / `end_time` can pad the series. + +## KDE + +[`KDE`][predspot.crime_mapping.KDE] fits a Gaussian kernel density estimate +(`scipy.stats.gaussian_kde`) to the event coordinates of each period and +evaluates it at the grid points. The result is a smooth density surface — +the classic hotspot map. + +```python +from predspot.crime_mapping import KDE + +mapping = KDE(tfreq="M", grid=points, bandwidth="silverman") +stseries = mapping.fit_transform(dataset.crimes) +stseries.head() +``` + +``` +t places +2019-01-31 0 12.31 + 1 14.02 + ... +Name: crime_density, dtype: float64 +``` + +`bandwidth` can be `"silverman"`, `"scott"` or a number. With a rule of thumb +the factor is estimated on the **first period with at least three events and +then held fixed**, so that densities are comparable across time; `mapping.factor` +tells you what was used. Periods with fewer than three events map to zero. + +## QuadratCount + +[`QuadratCount`][predspot.crime_mapping.QuadratCount] counts the events that +fall inside each polygonal cell. Counts are easier to interpret than +densities (they are numbers of crimes) and lend themselves to count models, +at the price of a blockier map and sensitivity to how the grid is laid out. + +```python +from predspot.crime_mapping import QuadratCount + +mapping = QuadratCount(tfreq="W", grid=hexes) +stseries = mapping.fit_transform(dataset.crimes) +stseries.groupby("t").sum() # events per week +``` + +
+ ![KDE vs QuadratCount](../assets/kde_vs_quadrat.png){ width="900" } +
+ +## Writing your own mapping + +Subclass [`SpatioTemporalMapping`][predspot.crime_mapping.SpatioTemporalMapping] +and implement `fit_grid(data_points)`, which receives the events of one period +and returns a `{place: value}` dict. The base class handles the time splitting, +empty periods and the assembly of the series, so your mapping is immediately +usable in a `PredictionPipeline`. + +```python +from predspot.crime_mapping import SpatioTemporalMapping + +class NearestCount(SpatioTemporalMapping): + """Number of events closer than 300 m to each grid point.""" + + def fit_grid(self, data_points): + pts = data_points.to_crs(self._grid.estimate_utm_crs()) + grid = self._grid.to_crs(pts.crs) + return {p: int(pts.distance(geom).lt(300).sum()) + for p, geom in grid.geometry.items()} +``` diff --git a/docs/guide/modelling.md b/docs/guide/modelling.md new file mode 100644 index 0000000..c3fa9c3 --- /dev/null +++ b/docs/guide/modelling.md @@ -0,0 +1,106 @@ +# Prediction and evaluation + +[`PredictionPipeline`][predspot.ml_modelling.PredictionPipeline] ties the +mapping, the features and a scikit-learn estimator together. + +## Fitting + +```python +from sklearn.ensemble import GradientBoostingRegressor +from predspot import PredictionPipeline, PandasFeatureUnion +from predspot.crime_mapping import KDE, create_gridpoints +from predspot.feature_engineering import AR, Seasonality, Trend + +pipeline = PredictionPipeline( + mapping=KDE(tfreq="M", grid=create_gridpoints(study_area, resolution=1)), + fextraction=PandasFeatureUnion([ + ("ar", AR(lags=3)), + ("seasonal", Seasonality(lags=12)), + ("trend", Trend(lags=12)), + ]), + estimator=GradientBoostingRegressor(random_state=0), + random_state=0, +) +pipeline.fit(dataset) +``` + +`fit` computes the spatio-temporal series (`pipeline.stseries`), the feature +matrix (`pipeline.features`) and trains the estimator on every `(t, places)` +row whose target is known. Rows are shuffled (`random_state`) since the +estimator sees them as independent samples. + +## Evaluating + +```python +pipeline.evaluate("r2", cv=5) # or "mse", or ["r2", "mse"] for a DataFrame +``` + +Periods are ordered and split with scikit-learn's `TimeSeriesSplit`, so each +fold trains on earlier periods and tests on later ones — no leakage from the +future. One score per fold is returned; the estimator is refitted on all the +data afterwards. + +!!! note + Scores measure how well the *density surface* of the next period is + predicted at every place. For patrol planning you may care more about the + ranking of places (e.g. hit rate of the top-*k* cells); compute it from + `pipeline.predict()` against the series of the following period. + +## Forecasting + +```python +forecast = pipeline.predict() # next period +forecast.head() +``` + +``` + crime_density +t places +2021-01-31 0 27.30 + 1 46.43 +``` + +Each `predict()` call appends its forecast to `pipeline.stseries`, recomputes +the features and advances `pipeline.next_time`, so repeated calls produce a +multi-step, recursive forecast. Join the result with `pipeline.grid` to map +it: + +```python +hot = pipeline.grid.join(forecast.droplevel("t")) +top = hot.nlargest(20, "crime_density") # top-20 places for patrol planning +hot.plot(column="crime_density", cmap="magma", markersize=12, legend=True) +``` + +
+ ![Forecast](../assets/forecast.png){ width="900" } +
+ +## Feature importances + +When the estimator (or the last step of a `Pipeline`) exposes +`feature_importances_`, `pipeline.feature_importances` returns them by +feature name, accounting for a `FeatureSelection` step if present: + +```python +pipeline.feature_importances.head() +``` + +## Modelling one crime type at a time + +Different crime types have different dynamics; the usual approach is one +pipeline per `tag`: + +```python +pipelines = {} +for tag, events in crimes.groupby("tag"): + pipelines[tag] = build_default_pipeline(study_area).fit(Dataset(events, study_area)) +``` + +## One-call helpers + +[`predspot.pipeline`][predspot.pipeline] offers +[`build_default_pipeline`][predspot.pipeline.build_default_pipeline] (KDE on +points, seasonal/trend/diff features, quantile scaling, RFE selection and a +random forest) and +[`run_prediction_pipeline`][predspot.pipeline.run_prediction_pipeline], which +also filters by tag and time of day and fits in one go. diff --git a/docs/hooks/readme.py b/docs/hooks/readme.py new file mode 100644 index 0000000..516e311 --- /dev/null +++ b/docs/hooks/readme.py @@ -0,0 +1,31 @@ +"""MkDocs hook: the documentation home page is the repository README. + +The README is written for GitHub (paths relative to the repository root); this +hook rewrites those paths so the same content renders on the site. +""" + +import re +from pathlib import Path + +README = Path(__file__).resolve().parents[2] / "README.md" + +REPO_BLOB = "https://github.com/adaj/predspot/blob/master/" +LINKS = { + REPO_BLOB + "CONTRIBUTING.md": "contributing.md", + REPO_BLOB + "CHANGELOG.md": "changelog.md", + REPO_BLOB + "examples/natal.ipynb": "examples/natal.ipynb", + "https://adaj.github.io/predspot/": "index.md", +} + + +def on_page_markdown(markdown, page, config, files): + if page.file.src_path != "index.md": + return markdown + text = README.read_text(encoding="utf-8") + text = text.replace('src="docs/assets/', 'src="assets/') + text = text.replace("](docs/assets/", "](assets/") + for url, target in LINKS.items(): + text = text.replace(f"]({url})", f"]({target})") + # Keep the page's own front matter (title etc.), if any. + front = re.match(r"^---\n.*?\n---\n", markdown, flags=re.S) + return (front.group(0) if front else "") + text diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..c4f5189 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,4 @@ +--- +title: Home +--- + diff --git a/docs/make.bat b/docs/make.bat deleted file mode 100644 index dc1312a..0000000 --- a/docs/make.bat +++ /dev/null @@ -1,35 +0,0 @@ -@ECHO OFF - -pushd %~dp0 - -REM Command file for Sphinx documentation - -if "%SPHINXBUILD%" == "" ( - set SPHINXBUILD=sphinx-build -) -set SOURCEDIR=source -set BUILDDIR=build - -%SPHINXBUILD% >NUL 2>NUL -if errorlevel 9009 ( - echo. - echo.The 'sphinx-build' command was not found. Make sure you have Sphinx - echo.installed, then set the SPHINXBUILD environment variable to point - echo.to the full path of the 'sphinx-build' executable. Alternatively you - echo.may add the Sphinx directory to PATH. - echo. - echo.If you don't have Sphinx installed, grab it from - echo.https://www.sphinx-doc.org/ - exit /b 1 -) - -if "%1" == "" goto help - -%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% -goto end - -:help -%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% - -:end -popd diff --git a/docs/source/conf.py b/docs/source/conf.py deleted file mode 100644 index 730d396..0000000 --- a/docs/source/conf.py +++ /dev/null @@ -1,58 +0,0 @@ -# Configuration file for the Sphinx documentation builder. -# -# For the full list of built-in configuration values, see the documentation: -# https://www.sphinx-doc.org/en/master/usage/configuration.html - -# -- Project information ----------------------------------------------------- -# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information - -import os -import sys -sys.path.insert(0, os.path.abspath('../..')) - -project = 'Predspot' -copyright = '2024, Adelson Araujo' -author = 'Adelson Araujo' -release = '1.0' - -# -- General configuration --------------------------------------------------- -# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration - -extensions = [ - 'sphinx.ext.autodoc', - 'sphinx.ext.napoleon', - 'sphinx.ext.viewcode', - 'sphinx.ext.githubpages', - 'sphinx.ext.intersphinx', - 'sphinx.ext.autosectionlabel', - 'sphinx.ext.todo', -] - -templates_path = ['_templates'] -exclude_patterns = [] - -# -- Options for HTML output ------------------------------------------------- -# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output - -html_theme = 'alabaster' -html_baseurl = 'https://adaj.github.io/predspot/' - -html_theme_options = { - 'description': 'Crime hotspot prediction using machine learning', - 'github_user': 'adaj', - 'github_repo': 'predspot', - 'github_button': True, - 'github_type': 'star', - 'github_count': True, - 'fixed_sidebar': True, - 'page_width': '1000px', - 'sidebar_width': '250px', - 'show_powered_by': True, - 'show_relbars': True, - 'sidebar_collapse': True, - 'sidebar_includehidden': True, - 'extra_nav_links': { - 'Project Github': 'https://github.com/adaj/predspot', - 'Documentation': 'https://adaj.github.io/predspot/', - } -} \ No newline at end of file diff --git a/docs/source/index.rst b/docs/source/index.rst deleted file mode 100644 index 3729b47..0000000 --- a/docs/source/index.rst +++ /dev/null @@ -1,177 +0,0 @@ -Predspot -======== - -Overview --------- - -Predspot is an early project for a Python library for spatio-temporal crime prediction and hotspot detection. It combines machine learning techniques with spatial analysis to help predict and visualize crime patterns across time and space. To properly use this library, you preferably need to have a crime dataset and a study area. - -The results of this library are not guaranteed to be good, but it is a good starting point for spatio-temporal crime prediction. This project is barely maintained, so please if you want to collaborate, open an issue or a PR. - -Important Notice ----------------- - -.. warning:: - This project was developed as part of a master's thesis and is currently in an archived state. While the core functionality exists, you may encounter compatibility issues with newer Python package versions. The code can work with some effort, but please note: - - * This is not production-ready software - * Some dependencies are outdated and may require specific versions - * You might need to modify some code to work with newer package versions - * The project was created for research purposes - - However, we believe the methodologies and approaches used here are still valuable! If you're interested in crime hotspot prediction, feel free to: - - * Use this as a reference implementation - * Adapt the code to modern dependencies - * Build upon these concepts for your own projects - * Contribute to modernizing the codebase - - We welcome anyone interested in reviving or learning from this project! - -Key Features ------------- -* Spatial and temporal crime mapping -* Feature engineering for time series data -* Machine learning-based prediction pipeline -* Crime hotspot detection using Kernel Density Estimation -* Visualization tools for crime patterns - -Quick Start ------------ - -Basic usage example: - -.. code-block:: python - - from predspot import Dataset, PredictionPipeline - from predspot.crime_mapping import KDE, create_gridpoints - from predspot.feature_engineering import Seasonality, Trend, Diff - - # Load and prepare data - dataset = Dataset(crimes_df, study_area_gdf) - - # Create prediction pipeline - pipeline = PredictionPipeline( - mapping=KDE(tfreq='M', grid=create_gridpoints(study_area, resolution=250)), - fextraction=PandasFeatureUnion([ - ('seasonal', Seasonality(lags=12)), - ('trend', Trend(lags=12)), - ('diff', Diff(lags=12)) - ]), - estimator=your_favorite_sklearn_model - ) - - # Fit and predict - pipeline.fit(dataset) - predictions = pipeline.predict() - -Modules -------- - -The library consists of four main modules: - -* :doc:`modules/dataset_preparation`: Module for preparing and managing crime datasets and study areas. -* :doc:`modules/crime_mapping`: Module for spatial and temporal crime mapping, including KDE-based hotspot detection. -* :doc:`modules/feature_engineering`: Module for time series feature engineering, including seasonality, trend, and difference features. -* :doc:`modules/ml_modelling`: Module that implements the prediction pipeline and model evaluation. - -And two utilities: - -* :doc:`modules/utilities`: Utility functions for data preparation and visualization. -* :doc:`modules/pipeline`: Functions for the prediction pipeline. - -Installation ------------- - -Create conda env and install requirements: - -.. code-block:: bash - - conda create -n predspot python=3.8 - conda activate predspot - conda install -y rtree geopandas # if doesnt work, do: `conda clean --all` - pip install pandas statsmodels==0.10.2 geojsoncontour stldecompose scikit-learn matplotlib descartes - pip install . - -Required dependencies: - -* pandas -* geopandas -* numpy -* scikit-learn -* scipy -* stldecompose -* matplotlib - -Input Data Format ------------------ - -The crime data should be a pandas DataFrame with the following required columns: - -* ``tag``: Crime type -* ``t``: Timestamp -* ``lon``: Longitude -* ``lat``: Latitude - -The study area should be a GeoDataFrame defining the geographical boundaries of interest. - -Resources ---------- - -For more information on the methods used in Predspot, please search more about these methods: - -* Kernel Density Estimation for crime hotspot detection -* Time series decomposition for feature engineering -* Spatio-temporal crime prediction techniques - -License -------- - -BSD 3-Clause. - -Contributing ------------- - -Contributions are welcome! Please feel free to submit a Pull Request. - -Guidelines for contributing: - -1. Fork the repository -2. Create your feature branch -3. Commit your changes -4. Push to the branch -5. Create a new Pull Request - -Citation --------- - -If you use Predspot in your research, please cite us: - -.. code-block:: text - - Araujo, A., & Cacho, N. (2019). Predspot: Predicting crime hotspots with machine learning. - Master's thesis, UFRN (Universidade Federal do Rio Grande do Norte), Natal, Brazil. - - Araújo, A., Cacho, N., Bezerra, L., Vieira, C., & Borges, J. (2018, June). - Towards a crime hotspot detection framework for patrol planning. - In 2018 IEEE 20th International Conference on High Performance Computing and Communications; - IEEE 16th International Conference on Smart City; - IEEE 4th International Conference on Data Science and Systems (HPCC/SmartCity/DSS) (pp. 1256-1263). IEEE. - -.. toctree:: - :maxdepth: 2 - :caption: Documentation: - - modules/dataset_preparation - modules/crime_mapping - modules/feature_engineering - modules/ml_modelling - modules/utilities - modules/pipeline - -Indices and tables -================== - -* :ref:`genindex` -* :ref:`modindex` -* :ref:`search` \ No newline at end of file diff --git a/docs/source/modules/crime_mapping.rst b/docs/source/modules/crime_mapping.rst deleted file mode 100644 index 4feb87a..0000000 --- a/docs/source/modules/crime_mapping.rst +++ /dev/null @@ -1,9 +0,0 @@ -Crime Mapping -============= - -.. automodule:: predspot.crime_mapping - :members: - :undoc-members: - :show-inheritance: - :no-index: - diff --git a/docs/source/modules/dataset_preparation.rst b/docs/source/modules/dataset_preparation.rst deleted file mode 100644 index ef4952c..0000000 --- a/docs/source/modules/dataset_preparation.rst +++ /dev/null @@ -1,8 +0,0 @@ -Dataset Preparation -=================== - -.. automodule:: predspot.dataset_preparation - :members: - :undoc-members: - :show-inheritance: - :no-index: diff --git a/docs/source/modules/feature_engineering.rst b/docs/source/modules/feature_engineering.rst deleted file mode 100644 index 1d4e0c2..0000000 --- a/docs/source/modules/feature_engineering.rst +++ /dev/null @@ -1,9 +0,0 @@ -Feature Engineering -=================== - -.. automodule:: predspot.feature_engineering - :members: - :undoc-members: - :show-inheritance: - :no-index: - diff --git a/docs/source/modules/ml_modelling.rst b/docs/source/modules/ml_modelling.rst deleted file mode 100644 index d272c45..0000000 --- a/docs/source/modules/ml_modelling.rst +++ /dev/null @@ -1,9 +0,0 @@ -ML Modelling -============ - -.. automodule:: predspot.ml_modelling - :members: - :undoc-members: - :show-inheritance: - :no-index: - diff --git a/docs/source/modules/pipeline.rst b/docs/source/modules/pipeline.rst deleted file mode 100644 index 4a43db0..0000000 --- a/docs/source/modules/pipeline.rst +++ /dev/null @@ -1,9 +0,0 @@ -Pipeline -======== - -.. automodule:: predspot.pipeline - :members: - :undoc-members: - :show-inheritance: - :no-index: - diff --git a/docs/source/modules/utilities.rst b/docs/source/modules/utilities.rst deleted file mode 100644 index c99dc1a..0000000 --- a/docs/source/modules/utilities.rst +++ /dev/null @@ -1,9 +0,0 @@ -Utilities -========= - -.. automodule:: predspot.utilities - :members: - :undoc-members: - :show-inheritance: - :no-index: - diff --git a/docs/update_docs.md b/docs/update_docs.md deleted file mode 100644 index 25f1354..0000000 --- a/docs/update_docs.md +++ /dev/null @@ -1,28 +0,0 @@ -``` -cd docs -make clean -make html -cd .. - -# Delete local gh-pages branch if it exists -git branch -D gh-pages - -# Delete remote gh-pages branch -git push origin --delete gh-pages - -# Create new gh-pages branch without history -git checkout --orphan gh-pages - -# Copy the built files to root -cp -r docs/build/html/* . - -# Add and commit -git add . -git commit -m "Deploy documentation" - -# Force push the new branch -git push -f origin gh-pages - -# Go back to your previous branch (probably main or master) -git checkout - -``` \ No newline at end of file diff --git a/examples/build_natal_notebook.py b/examples/build_natal_notebook.py new file mode 100644 index 0000000..b6e8863 --- /dev/null +++ b/examples/build_natal_notebook.py @@ -0,0 +1,515 @@ +"""Build examples/natal.ipynb from source cells. + +The notebook itself is the deliverable; this script only exists so that the +notebook can be regenerated (and re-executed) reproducibly: + + python examples/build_natal_notebook.py + jupyter nbconvert --to notebook --execute --inplace examples/natal.ipynb +""" + +from pathlib import Path + +import nbformat as nbf + +cells = [] + + +def md(text): + cells.append(nbf.v4.new_markdown_cell(text.strip())) + + +def code(text): + cells.append(nbf.v4.new_code_cell(text.strip())) + + +md(""" +# Predspot end to end: crime hotspots for Natal, Brazil + +This notebook walks through the whole Predspot workflow on the city of Natal +(Rio Grande do Norte, Brazil), using **synthetic** crime events so that it +runs anywhere without confidential police data: + +1. fetch the city boundary from OpenStreetMap; +2. generate synthetic crime events with spatial hotspots and temporal patterns; +3. build the `Dataset`, the spatial grids and the spatio-temporal series (KDE and counts); +4. extract time series features; +5. fit, evaluate and inspect a `PredictionPipeline`; +6. forecast the next months and check how well the true hotspots are recovered. + +Every intermediate object is displayed so you can see exactly what flows +between the steps. Requirements: `pip install "predspot[osm]" matplotlib`. +""") + +code(""" +import warnings + +import geopandas as gpd +import matplotlib.pyplot as plt +import numpy as np +import pandas as pd + +import predspot +from predspot import Dataset, PredictionPipeline, PandasFeatureUnion, generate_crimes, get_city_shape +from predspot.crime_mapping import KDE, QuadratCount, create_gridhexagonal, create_gridpoints +from predspot.feature_engineering import AR, Diff, Seasonality, Trend + +warnings.filterwarnings("ignore", category=UserWarning) +pd.set_option("display.width", 120) +pd.set_option("display.max_columns", 20) +plt.rcParams["figure.dpi"] = 100 + +print("predspot", predspot.__version__, "| geopandas", gpd.__version__, "| pandas", pd.__version__) +""") + +md(""" +## 1. Study area: the boundary of Natal + +`get_city_shape` geocodes a place name with [osmnx](https://osmnx.readthedocs.io) +(Nominatim) and returns its boundary as a GeoDataFrame in WGS84. The query +should be specific enough for the first match to be the municipality. +""") + +code(""" +city = get_city_shape("Natal, RN, Brazil") +city +""") + +code(""" +polygon = city.geometry.iloc[0] +print("geometry:", polygon.geom_type, "| vertices:", len(polygon.exterior.coords)) +print("bounds (W, S, E, N):", np.round(city.total_bounds, 4)) +area_km2 = city.to_crs(city.estimate_utm_crs()).area.iloc[0] / 1e6 +print(f"area: {area_km2:.1f} km²") + +ax = city.plot(figsize=(5, 6), color="#f2f2f2", edgecolor="black") +ax.set_title("Natal, RN (OpenStreetMap boundary)") +ax.set_axis_off() +""") + +md(""" +## 2. Synthetic crime events + +`generate_crimes` draws events from a space-time point process inside the +polygon: a mixture of Gaussian **hotspots** plus a uniform background in space, +and a temporal intensity with a linear **trend**, an **annual** cycle and +**day-of-week** / **hour-of-day** profiles. With `return_hotspots=True` we also +get the hotspot centres, which lets us check later whether the model finds them. +""") + +code(""" +crimes, hotspots = generate_crimes( + city, + n_events=12_000, + start="2019-01-01", + end="2021-12-31", + n_hotspots=5, + hotspot_share=0.65, + hotspot_sd_km=0.7, + trend=0.4, # +40% events from start to end + annual_amplitude=0.25, # busier around the peak month... + annual_peak_month=12, # ...December + seed=2019, + return_hotspots=True, +) +crimes.head(10) +""") + +code(""" +print(crimes.shape) +crimes.describe(include="all").T +""") + +code(""" +hotspots +""") + +code(""" +crimes["tag"].value_counts().to_frame("events").assign(share=lambda d: (d["events"] / len(crimes)).round(3)) +""") + +md(""" +### 2.1 Where and when do the events happen? +""") + +code(""" +fig, ax = plt.subplots(figsize=(7, 8)) +city.plot(ax=ax, color="#f7f7f7", edgecolor="black") +sample = crimes.sample(4000, random_state=0) +ax.scatter(sample["lon"], sample["lat"], s=3, alpha=0.35, color="#1f4e79", label="events (sample)") +hotspots.plot(ax=ax, color="#d62728", marker="*", markersize=150, zorder=5, label="hotspot centres") +ax.legend(loc="lower left") +ax.set_title("Synthetic crime events in Natal") +ax.set_axis_off() +""") + +code(""" +monthly = crimes.set_index("t").resample("ME").size().rename("events") +fig, axes = plt.subplots(1, 3, figsize=(15, 3.6)) +monthly.plot(ax=axes[0], marker="o") +axes[0].set_title("Events per month (trend + annual cycle)") +axes[0].set_xlabel("") +crimes["t"].dt.day_name().value_counts().reindex( + ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] +).plot.bar(ax=axes[1], color="#1f4e79") +axes[1].set_title("Events per weekday") +crimes["t"].dt.hour.value_counts().sort_index().plot.bar(ax=axes[2], color="#1f4e79", width=0.9) +axes[2].set_title("Events per hour of day") +fig.tight_layout() +""") + +md(""" +## 3. Dataset, grids and the spatio-temporal series + +`Dataset` validates the events (columns `tag`, `t`, `lon`, `lat`) and turns +them into a GeoDataFrame of points in WGS84, together with the study area. +""") + +code(""" +dataset = Dataset(crimes, city) +dataset +""") + +code(""" +dataset.crimes.head() +""") + +md(""" +### 3.1 Grids + +Predspot discretises the city into **places**. Two kinds of grid are built +below: a grid of **points** every 500 m (used by `KDE`) and a grid of +**hexagons** of 1 km² (used by `QuadratCount`). Only cells intersecting the +city are kept; each grid has `lon`/`lat` columns with the cell centroid and an +index named `places`. +""") + +code(""" +points = create_gridpoints(city, resolution=0.5) +hexes = create_gridhexagonal(city, resolution=1.0) +print(f"{len(points)} grid points at 500 m | {len(hexes)} hexagons of 1 km²") +points.head() +""") + +code(""" +fig, axes = plt.subplots(1, 2, figsize=(12, 7)) +city.boundary.plot(ax=axes[0], color="black") +points.plot(ax=axes[0], markersize=4, color="#1f4e79") +axes[0].set_title(f"Point grid, 500 m ({len(points)} places)") +city.boundary.plot(ax=axes[1], color="black") +hexes.plot(ax=axes[1], facecolor="none", edgecolor="#1f4e79", linewidth=0.6) +axes[1].set_title(f"Hexagonal grid, 1 km² ({len(hexes)} places)") +for ax in axes: + ax.set_axis_off() +""") + +md(""" +### 3.2 Spatio-temporal mapping + +A mapping assigns one value to every `(period, place)` pair. `KDE` fits a +Gaussian kernel density estimate to the events of each period and evaluates it +at the grid points; `QuadratCount` counts the events inside each polygon. Both +return the same object: a `pandas.Series` named `crime_density` with a +`(t, places)` MultiIndex — the **spatio-temporal series**. +""") + +code(""" +kde = KDE(tfreq="M", grid=points, bandwidth="silverman") +stseries = kde.fit_transform(dataset.crimes) +print(type(stseries).__name__, stseries.shape, "| KDE factor:", round(kde.factor, 4)) +stseries.head(8) +""") + +code(""" +# The same series in wide form: one row per month, one column per place +wide = stseries.unstack("places") +wide.iloc[:6, :8] +""") + +code(""" +counts = QuadratCount(tfreq="M", grid=hexes).fit_transform(dataset.crimes) +counts_wide = counts.unstack("places") +print("events per month recovered by the counts:", counts_wide.sum(axis=1).astype(int).head(3).tolist(), "...") +counts_wide.iloc[:6, :8] +""") + +code(""" +month = pd.Timestamp("2021-06-30") +fig, axes = plt.subplots(1, 2, figsize=(13, 6.5)) + +g = points.copy() +g["density"] = stseries.xs(month, level="t").reindex(points.index).values +g.plot(ax=axes[0], column="density", cmap="viridis", markersize=12, marker="s", legend=True, + legend_kwds={"shrink": 0.6, "label": "KDE density"}) +city.boundary.plot(ax=axes[0], color="black", linewidth=1) +hotspots.plot(ax=axes[0], color="#d62728", marker="*", markersize=150, zorder=5) +axes[0].set_title(f"KDE on the point grid - {month:%B %Y}") + +h = hexes.copy() +h["events"] = counts.xs(month, level="t").reindex(hexes.index).values +h.plot(ax=axes[1], column="events", cmap="viridis", edgecolor="white", linewidth=0.3, legend=True, + legend_kwds={"shrink": 0.6, "label": "events in the cell"}) +city.boundary.plot(ax=axes[1], color="black", linewidth=1) +hotspots.plot(ax=axes[1], color="#d62728", marker="*", markersize=150, zorder=5) +axes[1].set_title(f"QuadratCount on hexagons - {month:%B %Y}") +for ax in axes: + ax.set_axis_off() +""") + +md(""" +### 3.3 The KDE bandwidth + +`bandwidth="silverman"` estimates the kernel width from the spread of the +events in the first period and keeps it fixed, which makes densities comparable +over time but tends to over-smooth a whole city. A numeric `bandwidth` is used +directly as the KDE factor (a multiple of the city-wide spread of the events): +smaller values give sharper maps. Compare three settings for the same month — +this is the trade-off discussed in the thesis (Chapter 2, Figure 3). +""") + +code(""" +fig, axes = plt.subplots(1, 3, figsize=(16, 5.5)) +for ax, bw in zip(axes, ["silverman", 0.2, 0.08]): + m = KDE(tfreq="M", grid=points, bandwidth=bw) + st = m.fit_transform(dataset.crimes) + g = points.copy() + g["density"] = st.xs(month, level="t").reindex(points.index).values + g.plot(ax=ax, column="density", cmap="viridis", markersize=10, marker="s") + city.boundary.plot(ax=ax, color="black", linewidth=1) + hotspots.plot(ax=ax, color="#d62728", marker="*", markersize=120, zorder=5) + ax.set_title(f"bandwidth={bw!r} (factor {m.factor:.3f})") + ax.set_axis_off() +""") + +md(""" +## 4. Time series features + +From here on we use `bandwidth=0.2`, a middle ground between the +over-smoothed Silverman estimate and a noisy small kernel. + +Each place has a monthly series. The feature classes transform it (STL trend, +STL seasonal component, first difference or the raw series) and build `lags` +lagged columns. `PandasFeatureUnion` aligns them on the `(t, places)` index and +drops the warm-up rows. Note the extra row for the month **after** the last +observed one — that is the row the model will forecast. +""") + +code(""" +BANDWIDTH = 0.2 +stseries = KDE(tfreq="M", grid=points, bandwidth=BANDWIDTH).fit_transform(dataset.crimes) + +LAGS = 6 +fextraction = PandasFeatureUnion([ + ("ar", AR(lags=3)), + ("seasonal", Seasonality(lags=LAGS)), + ("trend", Trend(lags=LAGS)), + ("diff", Diff(lags=LAGS)), +]) +X = fextraction.fit_transform(stseries) +print(X.shape, "| periods:", X.index.get_level_values("t").min().date(), "->", X.index.get_level_values("t").max().date()) +X.head(8) +""") + +code(""" +# What the decomposition looks like for the busiest place +busiest = stseries.groupby("places").mean().idxmax() +ts = stseries.xs(busiest, level="places") +from statsmodels.tsa.seasonal import STL +res = STL(ts, period=LAGS).fit() +fig, axes = plt.subplots(4, 1, figsize=(10, 7), sharex=True) +ts.plot(ax=axes[0], title=f"place {busiest}: KDE density per month") +res.trend.plot(ax=axes[1], title="STL trend") +res.seasonal.plot(ax=axes[2], title=f"STL seasonal (period={LAGS})") +ts.diff().plot(ax=axes[3], title="first difference") +for ax in axes: + ax.set_xlabel("") +fig.tight_layout() +""") + +md(""" +## 5. Fit and evaluate a prediction pipeline + +The pipeline chains the mapping, the feature extraction and a scikit-learn +estimator. Here the estimator is itself a `Pipeline`: quantile scaling, +recursive feature elimination and a random forest, each wrapped so that +DataFrames (and the `(t, places)` index) survive every step. +""") + +code(""" +from sklearn.ensemble import RandomForestRegressor +from sklearn.feature_selection import RFE +from sklearn.pipeline import Pipeline +from sklearn.preprocessing import QuantileTransformer + +from predspot.feature_engineering import FeatureScaling +from predspot.ml_modelling import FeatureSelection, Model + +pipeline = PredictionPipeline( + mapping=KDE(tfreq="M", grid=points, bandwidth=BANDWIDTH), + fextraction=PandasFeatureUnion([ + ("ar", AR(lags=3)), + ("seasonal", Seasonality(lags=LAGS)), + ("trend", Trend(lags=LAGS)), + ("diff", Diff(lags=LAGS)), + ]), + estimator=Pipeline([ + ("scaling", FeatureScaling(QuantileTransformer(n_quantiles=20, output_distribution="uniform"))), + ("selection", FeatureSelection(RFE(RandomForestRegressor(n_estimators=20, random_state=0, n_jobs=-1), + n_features_to_select=12, step=3))), + ("model", Model(RandomForestRegressor(n_estimators=100, min_samples_leaf=2, random_state=0, n_jobs=-1))), + ]), + random_state=0, +) +pipeline.fit(dataset) +print("training rows:", pipeline.features.loc[: stseries.index.get_level_values("t").max()].shape[0]) +print("next period to forecast:", pipeline.next_time.date()) +""") + +code(""" +scores = pipeline.evaluate(["r2", "mse"], cv=3) # time series CV: train on earlier folds, test on the next +scores.loc["mean"] = scores.mean() +scores +""") + +code(""" +fi = pipeline.feature_importances +ax = fi.sort_values("importance").plot.barh(figsize=(7, 5), legend=False, color="#1f4e79") +ax.set_title("Selected features and their importance") +fi.head(12) +""") + +md(""" +## 6. Forecast the next months + +`predict()` returns the density of every place for the period after the last +observed one. Each call appends its forecast to the series, recomputes the +features and moves the horizon one period forward, so calling it three times +gives a three-month recursive forecast. +""") + +code(""" +forecasts = pd.concat([pipeline.predict() for _ in range(3)]) +forecasts.groupby("t").describe().round(2) +""") + +code(""" +first_month = forecasts.index.get_level_values("t").min() +fc = points.join(forecasts.xs(first_month, level="t")) +top10 = fc.nlargest(10, "crime_density")[["lon", "lat", "crime_density"]].round(4) +print(f"Top-10 places for {first_month:%B %Y}:") +top10 +""") + +code(""" +fig, ax = plt.subplots(figsize=(7, 8)) +fc.plot(ax=ax, column="crime_density", cmap="magma", markersize=14, marker="s", legend=True, + legend_kwds={"shrink": 0.6, "label": "forecast density"}) +city.boundary.plot(ax=ax, color="black", linewidth=1) +hotspots.plot(ax=ax, color="#00e5ff", marker="*", markersize=170, zorder=5, label="true hotspot centres") +top = fc.nlargest(20, "crime_density") +ax.scatter(top["lon"], top["lat"], s=110, facecolors="none", edgecolors="#00e5ff", linewidths=1.6, label="top-20 forecast") +ax.legend(loc="lower left") +ax.set_title(f"Forecast hotspots for {first_month:%B %Y}") +ax.set_axis_off() +""") + +md(""" +### 6.1 Does the forecast find the true hotspots? + +Since the data are synthetic we know where the hotspots are. For each true +hotspot centre we look up the nearest grid point and check how it ranks in the +forecast (1 = hottest place of the city). Hotspots holding a larger share of the +events should rank near the top; small hotspots on a 500 m grid compete with the +many grid points that surround the biggest one. +""") + +code(""" +def distance_km(lon1, lat1, lon2, lat2): + dx = (lon1 - lon2) * 111.32 * np.cos(np.radians((lat1 + lat2) / 2)) + dy = (lat1 - lat2) * 110.57 + return np.hypot(dx, dy) + +ranked = fc.sort_values("crime_density", ascending=False) +ranked["rank"] = np.arange(1, len(ranked) + 1) +rows = [] +for i, h in hotspots.iterrows(): + d = distance_km(ranked["lon"].values, ranked["lat"].values, h.geometry.x, h.geometry.y) + nearest = ranked.iloc[int(d.argmin())] + rows.append({ + "hotspot": i, + "share_of_events": round(h["share"], 3), + "nearest_place": int(nearest.name), + "distance_km": round(float(d.min()), 2), + "forecast_rank": int(nearest["rank"]), + "percentile": round(100 * (1 - nearest["rank"] / len(ranked)), 1), + }) +pd.DataFrame(rows).set_index("hotspot").sort_values("forecast_rank") +""") + +code(""" +# The full history + forecast of the top forecast place +place = fc["crime_density"].idxmax() +series = pipeline.stseries.xs(place, level="places") +observed = series.loc[: stseries.index.get_level_values("t").max()] +predicted = series.loc[forecasts.index.get_level_values("t").min():] +ax = observed.plot(figsize=(10, 3.5), marker="o", label="observed (KDE)") +predicted.plot(ax=ax, marker="*", markersize=12, linestyle="--", color="#d62728", label="forecast") +ax.set_title(f"Place {place}: monthly density and 3-month recursive forecast") +ax.set_xlabel("") +ax.legend() +""") + +md(""" +## 7. The same pipeline with counts on hexagons + +`QuadratCount` is a drop-in replacement for `KDE`: swap the mapping and the +grid, keep everything else. +""") + +code(""" +hex_pipeline = PredictionPipeline( + mapping=QuadratCount(tfreq="M", grid=hexes), + fextraction=PandasFeatureUnion([ + ("ar", AR(lags=3)), + ("seasonal", Seasonality(lags=LAGS)), + ("trend", Trend(lags=LAGS)), + ]), + estimator=RandomForestRegressor(n_estimators=100, min_samples_leaf=2, random_state=0, n_jobs=-1), + random_state=0, +).fit(dataset) +print("r2 per fold:", np.round(hex_pipeline.evaluate("r2", cv=3), 3)) +hex_fc = hexes.join(hex_pipeline.predict().droplevel("t")) + +fig, ax = plt.subplots(figsize=(7, 8)) +hex_fc.plot(ax=ax, column="crime_density", cmap="magma", edgecolor="white", linewidth=0.3, legend=True, + legend_kwds={"shrink": 0.6, "label": "forecast events"}) +city.boundary.plot(ax=ax, color="black", linewidth=1) +hotspots.plot(ax=ax, color="#00e5ff", marker="*", markersize=170, zorder=5) +ax.set_title(f"Forecast events per hexagon for {hex_pipeline.stseries.index.get_level_values('t').max():%B %Y}") +ax.set_axis_off() +""") + +md(""" +## Wrapping up + +- `get_city_shape` gave us the study area; `generate_crimes` produced events with known hotspots. +- `Dataset` → grid → `KDE` / `QuadratCount` turned the events into a spatio-temporal series. +- Lagged STL trend/seasonal, difference and autoregressive features fed a scikit-learn pipeline + wrapped by `PredictionPipeline`, evaluated with time series cross-validation. +- `predict()` forecast the following months; the grid points nearest to the true hotspot + centres rank at the top of the forecast, in proportion to each hotspot's share of events. + +To run this on real data, replace the synthetic `crimes` DataFrame with your own +`tag, t, lon, lat` table and model each crime type (`tag`) separately, as the +framework recommends. See the [documentation](https://adaj.github.io/predspot/) +for the details of every step. +""") + +nb = nbf.v4.new_notebook() +nb["cells"] = cells +nb["metadata"] = { + "kernelspec": {"display_name": "Python 3", "language": "python", "name": "python3"}, + "language_info": {"name": "python"}, +} +out = Path(__file__).with_name("natal.ipynb") +nbf.write(nb, out) +print(f"wrote {out} with {len(cells)} cells") diff --git a/examples/natal.ipynb b/examples/natal.ipynb new file mode 100644 index 0000000..e995e61 --- /dev/null +++ b/examples/natal.ipynb @@ -0,0 +1,728 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "7e4e38dc", + "metadata": {}, + "source": [ + "# Predspot end to end: crime hotspots for Natal, Brazil\n", + "\n", + "This notebook walks through the whole Predspot workflow on the city of Natal\n", + "(Rio Grande do Norte, Brazil), using **synthetic** crime events so that it\n", + "runs anywhere without confidential police data:\n", + "\n", + "1. fetch the city boundary from OpenStreetMap;\n", + "2. generate synthetic crime events with spatial hotspots and temporal patterns;\n", + "3. build the `Dataset`, the spatial grids and the spatio-temporal series (KDE and counts);\n", + "4. extract time series features;\n", + "5. fit, evaluate and inspect a `PredictionPipeline`;\n", + "6. forecast the next months and check how well the true hotspots are recovered.\n", + "\n", + "Every intermediate object is displayed so you can see exactly what flows\n", + "between the steps. Requirements: `pip install \"predspot[osm]\" matplotlib`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1dac71a9", + "metadata": {}, + "outputs": [], + "source": [ + "import warnings\n", + "\n", + "import geopandas as gpd\n", + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "import predspot\n", + "from predspot import Dataset, PredictionPipeline, PandasFeatureUnion, generate_crimes, get_city_shape\n", + "from predspot.crime_mapping import KDE, QuadratCount, create_gridhexagonal, create_gridpoints\n", + "from predspot.feature_engineering import AR, Diff, Seasonality, Trend\n", + "\n", + "warnings.filterwarnings(\"ignore\", category=UserWarning)\n", + "pd.set_option(\"display.width\", 120)\n", + "pd.set_option(\"display.max_columns\", 20)\n", + "plt.rcParams[\"figure.dpi\"] = 100\n", + "\n", + "print(\"predspot\", predspot.__version__, \"| geopandas\", gpd.__version__, \"| pandas\", pd.__version__)" + ] + }, + { + "cell_type": "markdown", + "id": "cb9de6ba", + "metadata": {}, + "source": [ + "## 1. Study area: the boundary of Natal\n", + "\n", + "`get_city_shape` geocodes a place name with [osmnx](https://osmnx.readthedocs.io)\n", + "(Nominatim) and returns its boundary as a GeoDataFrame in WGS84. The query\n", + "should be specific enough for the first match to be the municipality." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b0fbc889", + "metadata": {}, + "outputs": [], + "source": [ + "city = get_city_shape(\"Natal, RN, Brazil\")\n", + "city" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "88ba713c", + "metadata": {}, + "outputs": [], + "source": [ + "polygon = city.geometry.iloc[0]\n", + "print(\"geometry:\", polygon.geom_type, \"| vertices:\", len(polygon.exterior.coords))\n", + "print(\"bounds (W, S, E, N):\", np.round(city.total_bounds, 4))\n", + "area_km2 = city.to_crs(city.estimate_utm_crs()).area.iloc[0] / 1e6\n", + "print(f\"area: {area_km2:.1f} km²\")\n", + "\n", + "ax = city.plot(figsize=(5, 6), color=\"#f2f2f2\", edgecolor=\"black\")\n", + "ax.set_title(\"Natal, RN (OpenStreetMap boundary)\")\n", + "ax.set_axis_off()" + ] + }, + { + "cell_type": "markdown", + "id": "e1f4ca42", + "metadata": {}, + "source": [ + "## 2. Synthetic crime events\n", + "\n", + "`generate_crimes` draws events from a space-time point process inside the\n", + "polygon: a mixture of Gaussian **hotspots** plus a uniform background in space,\n", + "and a temporal intensity with a linear **trend**, an **annual** cycle and\n", + "**day-of-week** / **hour-of-day** profiles. With `return_hotspots=True` we also\n", + "get the hotspot centres, which lets us check later whether the model finds them." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "caf16e7f", + "metadata": {}, + "outputs": [], + "source": [ + "crimes, hotspots = generate_crimes(\n", + " city,\n", + " n_events=12_000,\n", + " start=\"2019-01-01\",\n", + " end=\"2021-12-31\",\n", + " n_hotspots=5,\n", + " hotspot_share=0.65,\n", + " hotspot_sd_km=0.7,\n", + " trend=0.4, # +40% events from start to end\n", + " annual_amplitude=0.25, # busier around the peak month...\n", + " annual_peak_month=12, # ...December\n", + " seed=2019,\n", + " return_hotspots=True,\n", + ")\n", + "crimes.head(10)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "256277d3", + "metadata": {}, + "outputs": [], + "source": [ + "print(crimes.shape)\n", + "crimes.describe(include=\"all\").T" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "eb00b456", + "metadata": {}, + "outputs": [], + "source": [ + "hotspots" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "999d61a3", + "metadata": {}, + "outputs": [], + "source": [ + "crimes[\"tag\"].value_counts().to_frame(\"events\").assign(share=lambda d: (d[\"events\"] / len(crimes)).round(3))" + ] + }, + { + "cell_type": "markdown", + "id": "bd6b0177", + "metadata": {}, + "source": [ + "### 2.1 Where and when do the events happen?" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4f4153c8", + "metadata": {}, + "outputs": [], + "source": [ + "fig, ax = plt.subplots(figsize=(7, 8))\n", + "city.plot(ax=ax, color=\"#f7f7f7\", edgecolor=\"black\")\n", + "sample = crimes.sample(4000, random_state=0)\n", + "ax.scatter(sample[\"lon\"], sample[\"lat\"], s=3, alpha=0.35, color=\"#1f4e79\", label=\"events (sample)\")\n", + "hotspots.plot(ax=ax, color=\"#d62728\", marker=\"*\", markersize=150, zorder=5, label=\"hotspot centres\")\n", + "ax.legend(loc=\"lower left\")\n", + "ax.set_title(\"Synthetic crime events in Natal\")\n", + "ax.set_axis_off()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b15e0701", + "metadata": {}, + "outputs": [], + "source": [ + "monthly = crimes.set_index(\"t\").resample(\"ME\").size().rename(\"events\")\n", + "fig, axes = plt.subplots(1, 3, figsize=(15, 3.6))\n", + "monthly.plot(ax=axes[0], marker=\"o\")\n", + "axes[0].set_title(\"Events per month (trend + annual cycle)\")\n", + "axes[0].set_xlabel(\"\")\n", + "crimes[\"t\"].dt.day_name().value_counts().reindex(\n", + " [\"Monday\", \"Tuesday\", \"Wednesday\", \"Thursday\", \"Friday\", \"Saturday\", \"Sunday\"]\n", + ").plot.bar(ax=axes[1], color=\"#1f4e79\")\n", + "axes[1].set_title(\"Events per weekday\")\n", + "crimes[\"t\"].dt.hour.value_counts().sort_index().plot.bar(ax=axes[2], color=\"#1f4e79\", width=0.9)\n", + "axes[2].set_title(\"Events per hour of day\")\n", + "fig.tight_layout()" + ] + }, + { + "cell_type": "markdown", + "id": "b0340fd2", + "metadata": {}, + "source": [ + "## 3. Dataset, grids and the spatio-temporal series\n", + "\n", + "`Dataset` validates the events (columns `tag`, `t`, `lon`, `lat`) and turns\n", + "them into a GeoDataFrame of points in WGS84, together with the study area." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8a810174", + "metadata": {}, + "outputs": [], + "source": [ + "dataset = Dataset(crimes, city)\n", + "dataset" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d915daaf", + "metadata": {}, + "outputs": [], + "source": [ + "dataset.crimes.head()" + ] + }, + { + "cell_type": "markdown", + "id": "5552a527", + "metadata": {}, + "source": [ + "### 3.1 Grids\n", + "\n", + "Predspot discretises the city into **places**. Two kinds of grid are built\n", + "below: a grid of **points** every 500 m (used by `KDE`) and a grid of\n", + "**hexagons** of 1 km² (used by `QuadratCount`). Only cells intersecting the\n", + "city are kept; each grid has `lon`/`lat` columns with the cell centroid and an\n", + "index named `places`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "534bcc4d", + "metadata": {}, + "outputs": [], + "source": [ + "points = create_gridpoints(city, resolution=0.5)\n", + "hexes = create_gridhexagonal(city, resolution=1.0)\n", + "print(f\"{len(points)} grid points at 500 m | {len(hexes)} hexagons of 1 km²\")\n", + "points.head()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "77bc64fc", + "metadata": {}, + "outputs": [], + "source": [ + "fig, axes = plt.subplots(1, 2, figsize=(12, 7))\n", + "city.boundary.plot(ax=axes[0], color=\"black\")\n", + "points.plot(ax=axes[0], markersize=4, color=\"#1f4e79\")\n", + "axes[0].set_title(f\"Point grid, 500 m ({len(points)} places)\")\n", + "city.boundary.plot(ax=axes[1], color=\"black\")\n", + "hexes.plot(ax=axes[1], facecolor=\"none\", edgecolor=\"#1f4e79\", linewidth=0.6)\n", + "axes[1].set_title(f\"Hexagonal grid, 1 km² ({len(hexes)} places)\")\n", + "for ax in axes:\n", + " ax.set_axis_off()" + ] + }, + { + "cell_type": "markdown", + "id": "e96fee0a", + "metadata": {}, + "source": [ + "### 3.2 Spatio-temporal mapping\n", + "\n", + "A mapping assigns one value to every `(period, place)` pair. `KDE` fits a\n", + "Gaussian kernel density estimate to the events of each period and evaluates it\n", + "at the grid points; `QuadratCount` counts the events inside each polygon. Both\n", + "return the same object: a `pandas.Series` named `crime_density` with a\n", + "`(t, places)` MultiIndex — the **spatio-temporal series**." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "110f6eb3", + "metadata": {}, + "outputs": [], + "source": [ + "kde = KDE(tfreq=\"M\", grid=points, bandwidth=\"silverman\")\n", + "stseries = kde.fit_transform(dataset.crimes)\n", + "print(type(stseries).__name__, stseries.shape, \"| KDE factor:\", round(kde.factor, 4))\n", + "stseries.head(8)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d9cf26da", + "metadata": {}, + "outputs": [], + "source": [ + "# The same series in wide form: one row per month, one column per place\n", + "wide = stseries.unstack(\"places\")\n", + "wide.iloc[:6, :8]" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b92ac677", + "metadata": {}, + "outputs": [], + "source": [ + "counts = QuadratCount(tfreq=\"M\", grid=hexes).fit_transform(dataset.crimes)\n", + "counts_wide = counts.unstack(\"places\")\n", + "print(\"events per month recovered by the counts:\", counts_wide.sum(axis=1).astype(int).head(3).tolist(), \"...\")\n", + "counts_wide.iloc[:6, :8]" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ef9896a8", + "metadata": {}, + "outputs": [], + "source": [ + "month = pd.Timestamp(\"2021-06-30\")\n", + "fig, axes = plt.subplots(1, 2, figsize=(13, 6.5))\n", + "\n", + "g = points.copy()\n", + "g[\"density\"] = stseries.xs(month, level=\"t\").reindex(points.index).values\n", + "g.plot(ax=axes[0], column=\"density\", cmap=\"viridis\", markersize=12, marker=\"s\", legend=True,\n", + " legend_kwds={\"shrink\": 0.6, \"label\": \"KDE density\"})\n", + "city.boundary.plot(ax=axes[0], color=\"black\", linewidth=1)\n", + "hotspots.plot(ax=axes[0], color=\"#d62728\", marker=\"*\", markersize=150, zorder=5)\n", + "axes[0].set_title(f\"KDE on the point grid - {month:%B %Y}\")\n", + "\n", + "h = hexes.copy()\n", + "h[\"events\"] = counts.xs(month, level=\"t\").reindex(hexes.index).values\n", + "h.plot(ax=axes[1], column=\"events\", cmap=\"viridis\", edgecolor=\"white\", linewidth=0.3, legend=True,\n", + " legend_kwds={\"shrink\": 0.6, \"label\": \"events in the cell\"})\n", + "city.boundary.plot(ax=axes[1], color=\"black\", linewidth=1)\n", + "hotspots.plot(ax=axes[1], color=\"#d62728\", marker=\"*\", markersize=150, zorder=5)\n", + "axes[1].set_title(f\"QuadratCount on hexagons - {month:%B %Y}\")\n", + "for ax in axes:\n", + " ax.set_axis_off()" + ] + }, + { + "cell_type": "markdown", + "id": "c0a2c16f", + "metadata": {}, + "source": [ + "### 3.3 The KDE bandwidth\n", + "\n", + "`bandwidth=\"silverman\"` estimates the kernel width from the spread of the\n", + "events in the first period and keeps it fixed, which makes densities comparable\n", + "over time but tends to over-smooth a whole city. A numeric `bandwidth` is used\n", + "directly as the KDE factor (a multiple of the city-wide spread of the events):\n", + "smaller values give sharper maps. Compare three settings for the same month —\n", + "this is the trade-off discussed in the thesis (Chapter 2, Figure 3)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "859b64f4", + "metadata": {}, + "outputs": [], + "source": [ + "fig, axes = plt.subplots(1, 3, figsize=(16, 5.5))\n", + "for ax, bw in zip(axes, [\"silverman\", 0.2, 0.08]):\n", + " m = KDE(tfreq=\"M\", grid=points, bandwidth=bw)\n", + " st = m.fit_transform(dataset.crimes)\n", + " g = points.copy()\n", + " g[\"density\"] = st.xs(month, level=\"t\").reindex(points.index).values\n", + " g.plot(ax=ax, column=\"density\", cmap=\"viridis\", markersize=10, marker=\"s\")\n", + " city.boundary.plot(ax=ax, color=\"black\", linewidth=1)\n", + " hotspots.plot(ax=ax, color=\"#d62728\", marker=\"*\", markersize=120, zorder=5)\n", + " ax.set_title(f\"bandwidth={bw!r} (factor {m.factor:.3f})\")\n", + " ax.set_axis_off()" + ] + }, + { + "cell_type": "markdown", + "id": "47e84097", + "metadata": {}, + "source": [ + "## 4. Time series features\n", + "\n", + "From here on we use `bandwidth=0.2`, a middle ground between the\n", + "over-smoothed Silverman estimate and a noisy small kernel.\n", + "\n", + "Each place has a monthly series. The feature classes transform it (STL trend,\n", + "STL seasonal component, first difference or the raw series) and build `lags`\n", + "lagged columns. `PandasFeatureUnion` aligns them on the `(t, places)` index and\n", + "drops the warm-up rows. Note the extra row for the month **after** the last\n", + "observed one — that is the row the model will forecast." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1d901dc3", + "metadata": {}, + "outputs": [], + "source": [ + "BANDWIDTH = 0.2\n", + "stseries = KDE(tfreq=\"M\", grid=points, bandwidth=BANDWIDTH).fit_transform(dataset.crimes)\n", + "\n", + "LAGS = 6\n", + "fextraction = PandasFeatureUnion([\n", + " (\"ar\", AR(lags=3)),\n", + " (\"seasonal\", Seasonality(lags=LAGS)),\n", + " (\"trend\", Trend(lags=LAGS)),\n", + " (\"diff\", Diff(lags=LAGS)),\n", + "])\n", + "X = fextraction.fit_transform(stseries)\n", + "print(X.shape, \"| periods:\", X.index.get_level_values(\"t\").min().date(), \"->\", X.index.get_level_values(\"t\").max().date())\n", + "X.head(8)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "66dd5428", + "metadata": {}, + "outputs": [], + "source": [ + "# What the decomposition looks like for the busiest place\n", + "busiest = stseries.groupby(\"places\").mean().idxmax()\n", + "ts = stseries.xs(busiest, level=\"places\")\n", + "from statsmodels.tsa.seasonal import STL\n", + "res = STL(ts, period=LAGS).fit()\n", + "fig, axes = plt.subplots(4, 1, figsize=(10, 7), sharex=True)\n", + "ts.plot(ax=axes[0], title=f\"place {busiest}: KDE density per month\")\n", + "res.trend.plot(ax=axes[1], title=\"STL trend\")\n", + "res.seasonal.plot(ax=axes[2], title=f\"STL seasonal (period={LAGS})\")\n", + "ts.diff().plot(ax=axes[3], title=\"first difference\")\n", + "for ax in axes:\n", + " ax.set_xlabel(\"\")\n", + "fig.tight_layout()" + ] + }, + { + "cell_type": "markdown", + "id": "0d3cca51", + "metadata": {}, + "source": [ + "## 5. Fit and evaluate a prediction pipeline\n", + "\n", + "The pipeline chains the mapping, the feature extraction and a scikit-learn\n", + "estimator. Here the estimator is itself a `Pipeline`: quantile scaling,\n", + "recursive feature elimination and a random forest, each wrapped so that\n", + "DataFrames (and the `(t, places)` index) survive every step." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9a613809", + "metadata": {}, + "outputs": [], + "source": [ + "from sklearn.ensemble import RandomForestRegressor\n", + "from sklearn.feature_selection import RFE\n", + "from sklearn.pipeline import Pipeline\n", + "from sklearn.preprocessing import QuantileTransformer\n", + "\n", + "from predspot.feature_engineering import FeatureScaling\n", + "from predspot.ml_modelling import FeatureSelection, Model\n", + "\n", + "pipeline = PredictionPipeline(\n", + " mapping=KDE(tfreq=\"M\", grid=points, bandwidth=BANDWIDTH),\n", + " fextraction=PandasFeatureUnion([\n", + " (\"ar\", AR(lags=3)),\n", + " (\"seasonal\", Seasonality(lags=LAGS)),\n", + " (\"trend\", Trend(lags=LAGS)),\n", + " (\"diff\", Diff(lags=LAGS)),\n", + " ]),\n", + " estimator=Pipeline([\n", + " (\"scaling\", FeatureScaling(QuantileTransformer(n_quantiles=20, output_distribution=\"uniform\"))),\n", + " (\"selection\", FeatureSelection(RFE(RandomForestRegressor(n_estimators=20, random_state=0, n_jobs=-1),\n", + " n_features_to_select=12, step=3))),\n", + " (\"model\", Model(RandomForestRegressor(n_estimators=100, min_samples_leaf=2, random_state=0, n_jobs=-1))),\n", + " ]),\n", + " random_state=0,\n", + ")\n", + "pipeline.fit(dataset)\n", + "print(\"training rows:\", pipeline.features.loc[: stseries.index.get_level_values(\"t\").max()].shape[0])\n", + "print(\"next period to forecast:\", pipeline.next_time.date())" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "eee77d05", + "metadata": {}, + "outputs": [], + "source": [ + "scores = pipeline.evaluate([\"r2\", \"mse\"], cv=3) # time series CV: train on earlier folds, test on the next\n", + "scores.loc[\"mean\"] = scores.mean()\n", + "scores" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8f797ad0", + "metadata": {}, + "outputs": [], + "source": [ + "fi = pipeline.feature_importances\n", + "ax = fi.sort_values(\"importance\").plot.barh(figsize=(7, 5), legend=False, color=\"#1f4e79\")\n", + "ax.set_title(\"Selected features and their importance\")\n", + "fi.head(12)" + ] + }, + { + "cell_type": "markdown", + "id": "47c7fc5c", + "metadata": {}, + "source": [ + "## 6. Forecast the next months\n", + "\n", + "`predict()` returns the density of every place for the period after the last\n", + "observed one. Each call appends its forecast to the series, recomputes the\n", + "features and moves the horizon one period forward, so calling it three times\n", + "gives a three-month recursive forecast." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "18edefe0", + "metadata": {}, + "outputs": [], + "source": [ + "forecasts = pd.concat([pipeline.predict() for _ in range(3)])\n", + "forecasts.groupby(\"t\").describe().round(2)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "af03b229", + "metadata": {}, + "outputs": [], + "source": [ + "first_month = forecasts.index.get_level_values(\"t\").min()\n", + "fc = points.join(forecasts.xs(first_month, level=\"t\"))\n", + "top10 = fc.nlargest(10, \"crime_density\")[[\"lon\", \"lat\", \"crime_density\"]].round(4)\n", + "print(f\"Top-10 places for {first_month:%B %Y}:\")\n", + "top10" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2bc5e223", + "metadata": {}, + "outputs": [], + "source": [ + "fig, ax = plt.subplots(figsize=(7, 8))\n", + "fc.plot(ax=ax, column=\"crime_density\", cmap=\"magma\", markersize=14, marker=\"s\", legend=True,\n", + " legend_kwds={\"shrink\": 0.6, \"label\": \"forecast density\"})\n", + "city.boundary.plot(ax=ax, color=\"black\", linewidth=1)\n", + "hotspots.plot(ax=ax, color=\"#00e5ff\", marker=\"*\", markersize=170, zorder=5, label=\"true hotspot centres\")\n", + "top = fc.nlargest(20, \"crime_density\")\n", + "ax.scatter(top[\"lon\"], top[\"lat\"], s=110, facecolors=\"none\", edgecolors=\"#00e5ff\", linewidths=1.6, label=\"top-20 forecast\")\n", + "ax.legend(loc=\"lower left\")\n", + "ax.set_title(f\"Forecast hotspots for {first_month:%B %Y}\")\n", + "ax.set_axis_off()" + ] + }, + { + "cell_type": "markdown", + "id": "822a11ea", + "metadata": {}, + "source": [ + "### 6.1 Does the forecast find the true hotspots?\n", + "\n", + "Since the data are synthetic we know where the hotspots are. For each true\n", + "hotspot centre we look up the nearest grid point and check how it ranks in the\n", + "forecast (1 = hottest place of the city). Hotspots holding a larger share of the\n", + "events should rank near the top; small hotspots on a 500 m grid compete with the\n", + "many grid points that surround the biggest one." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cb82e3a9", + "metadata": {}, + "outputs": [], + "source": [ + "def distance_km(lon1, lat1, lon2, lat2):\n", + " dx = (lon1 - lon2) * 111.32 * np.cos(np.radians((lat1 + lat2) / 2))\n", + " dy = (lat1 - lat2) * 110.57\n", + " return np.hypot(dx, dy)\n", + "\n", + "ranked = fc.sort_values(\"crime_density\", ascending=False)\n", + "ranked[\"rank\"] = np.arange(1, len(ranked) + 1)\n", + "rows = []\n", + "for i, h in hotspots.iterrows():\n", + " d = distance_km(ranked[\"lon\"].values, ranked[\"lat\"].values, h.geometry.x, h.geometry.y)\n", + " nearest = ranked.iloc[int(d.argmin())]\n", + " rows.append({\n", + " \"hotspot\": i,\n", + " \"share_of_events\": round(h[\"share\"], 3),\n", + " \"nearest_place\": int(nearest.name),\n", + " \"distance_km\": round(float(d.min()), 2),\n", + " \"forecast_rank\": int(nearest[\"rank\"]),\n", + " \"percentile\": round(100 * (1 - nearest[\"rank\"] / len(ranked)), 1),\n", + " })\n", + "pd.DataFrame(rows).set_index(\"hotspot\").sort_values(\"forecast_rank\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c3350c32", + "metadata": {}, + "outputs": [], + "source": [ + "# The full history + forecast of the top forecast place\n", + "place = fc[\"crime_density\"].idxmax()\n", + "series = pipeline.stseries.xs(place, level=\"places\")\n", + "observed = series.loc[: stseries.index.get_level_values(\"t\").max()]\n", + "predicted = series.loc[forecasts.index.get_level_values(\"t\").min():]\n", + "ax = observed.plot(figsize=(10, 3.5), marker=\"o\", label=\"observed (KDE)\")\n", + "predicted.plot(ax=ax, marker=\"*\", markersize=12, linestyle=\"--\", color=\"#d62728\", label=\"forecast\")\n", + "ax.set_title(f\"Place {place}: monthly density and 3-month recursive forecast\")\n", + "ax.set_xlabel(\"\")\n", + "ax.legend()" + ] + }, + { + "cell_type": "markdown", + "id": "f01c22b2", + "metadata": {}, + "source": [ + "## 7. The same pipeline with counts on hexagons\n", + "\n", + "`QuadratCount` is a drop-in replacement for `KDE`: swap the mapping and the\n", + "grid, keep everything else." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8c8e650f", + "metadata": {}, + "outputs": [], + "source": [ + "hex_pipeline = PredictionPipeline(\n", + " mapping=QuadratCount(tfreq=\"M\", grid=hexes),\n", + " fextraction=PandasFeatureUnion([\n", + " (\"ar\", AR(lags=3)),\n", + " (\"seasonal\", Seasonality(lags=LAGS)),\n", + " (\"trend\", Trend(lags=LAGS)),\n", + " ]),\n", + " estimator=RandomForestRegressor(n_estimators=100, min_samples_leaf=2, random_state=0, n_jobs=-1),\n", + " random_state=0,\n", + ").fit(dataset)\n", + "print(\"r2 per fold:\", np.round(hex_pipeline.evaluate(\"r2\", cv=3), 3))\n", + "hex_fc = hexes.join(hex_pipeline.predict().droplevel(\"t\"))\n", + "\n", + "fig, ax = plt.subplots(figsize=(7, 8))\n", + "hex_fc.plot(ax=ax, column=\"crime_density\", cmap=\"magma\", edgecolor=\"white\", linewidth=0.3, legend=True,\n", + " legend_kwds={\"shrink\": 0.6, \"label\": \"forecast events\"})\n", + "city.boundary.plot(ax=ax, color=\"black\", linewidth=1)\n", + "hotspots.plot(ax=ax, color=\"#00e5ff\", marker=\"*\", markersize=170, zorder=5)\n", + "ax.set_title(f\"Forecast events per hexagon for {hex_pipeline.stseries.index.get_level_values('t').max():%B %Y}\")\n", + "ax.set_axis_off()" + ] + }, + { + "cell_type": "markdown", + "id": "08c02abe", + "metadata": {}, + "source": [ + "## Wrapping up\n", + "\n", + "- `get_city_shape` gave us the study area; `generate_crimes` produced events with known hotspots.\n", + "- `Dataset` → grid → `KDE` / `QuadratCount` turned the events into a spatio-temporal series.\n", + "- Lagged STL trend/seasonal, difference and autoregressive features fed a scikit-learn pipeline\n", + " wrapped by `PredictionPipeline`, evaluated with time series cross-validation.\n", + "- `predict()` forecast the following months; the grid points nearest to the true hotspot\n", + " centres rank at the top of the forecast, in proportion to each hotspot's share of events.\n", + "\n", + "To run this on real data, replace the synthetic `crimes` DataFrame with your own\n", + "`tag, t, lon, lat` table and model each crime type (`tag`) separately, as the\n", + "framework recommends. See the [documentation](https://adaj.github.io/predspot/)\n", + "for the details of every step." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/install.md b/install.md deleted file mode 100644 index 16269a2..0000000 --- a/install.md +++ /dev/null @@ -1,6 +0,0 @@ -``` -$ git clone https://github.com/adaj/predspot.git -$ cd predspot -$ pip install . # Python >= 3.10 -$ pip install pytest && pytest -``` diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..d91c589 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,129 @@ +site_name: Predspot +site_description: Predicting crime hotspots with machine learning +site_url: https://adaj.github.io/predspot/ +repo_url: https://github.com/adaj/predspot +repo_name: adaj/predspot +edit_uri: edit/master/docs/ +copyright: Copyright © 2019-2026 Adelson Araujo — BSD-3-Clause + +theme: + name: material + language: en + icon: + repo: fontawesome/brands/github + palette: + - media: "(prefers-color-scheme: light)" + scheme: default + primary: deep purple + accent: amber + toggle: + icon: material/weather-night + name: Switch to dark mode + - media: "(prefers-color-scheme: dark)" + scheme: slate + primary: deep purple + accent: amber + toggle: + icon: material/weather-sunny + name: Switch to light mode + features: + - navigation.tabs + - navigation.sections + - navigation.top + - navigation.footer + - search.suggest + - search.highlight + - content.code.copy + - content.code.annotate + - toc.follow + +nav: + - Home: index.md + - Getting started: + - Installation: getting-started/installation.md + - Quickstart: getting-started/quickstart.md + - User guide: + - How Predspot works: guide/concepts.md + - Data and study areas: guide/data.md + - Grids and spatio-temporal mapping: guide/mapping.md + - Feature engineering: guide/features.md + - Prediction and evaluation: guide/modelling.md + - Examples: + - Natal, Brazil (end to end): examples/natal.ipynb + - API reference: + - predspot: api/predspot.md + - dataset_preparation: api/dataset_preparation.md + - crime_mapping: api/crime_mapping.md + - feature_engineering: api/feature_engineering.md + - ml_modelling: api/ml_modelling.md + - synthetic: api/synthetic.md + - pipeline: api/pipeline.md + - utilities: api/utilities.md + - Project: + - Changelog: changelog.md + - Contributing: contributing.md + - Citing Predspot: citing.md + +hooks: + - docs/hooks/readme.py + +plugins: + - search + - mkdocs-jupyter: + execute: false + include_source: true + include_requirejs: true + - autorefs + - mkdocstrings: + handlers: + python: + paths: [src] + options: + docstring_style: google + docstring_options: + returns_named_value: false + returns_multiple_items: false + ignore_init_summary: true + docstring_section_style: table + show_source: true + show_root_heading: true + show_symbol_type_heading: true + show_symbol_type_toc: true + members_order: source + separate_signature: true + show_signature_annotations: false + merge_init_into_class: true + filters: ["!^_"] + +markdown_extensions: + - admonition + - attr_list + - md_in_html + - toc: + permalink: true + - pymdownx.details + - pymdownx.superfences: + custom_fences: + - name: mermaid + class: mermaid + format: !!python/name:pymdownx.superfences.fence_code_format + - pymdownx.tabbed: + alternate_style: true + - pymdownx.highlight: + anchor_linenums: true + - pymdownx.inlinehilite + - pymdownx.snippets: + check_paths: true + base_path: [".", "docs"] + - pymdownx.arithmatex: + generic: true + +extra_javascript: + - https://unpkg.com/mathjax@3/es5/tex-mml-chtml.js + +extra: + social: + - icon: fontawesome/brands/github + link: https://github.com/adaj/predspot + - icon: fontawesome/brands/python + link: https://pypi.org/project/predspot/ diff --git a/predspot/__init__.py b/predspot/__init__.py deleted file mode 100644 index 9ba0d1f..0000000 --- a/predspot/__init__.py +++ /dev/null @@ -1,28 +0,0 @@ -""" -Predspot — predicting crime hotspots with machine learning. - -Typical use:: - - from predspot import Dataset, PredictionPipeline - from predspot.crime_mapping import KDE, create_gridpoints - from predspot.feature_engineering import Seasonality, Trend, Diff - from predspot.utilities import PandasFeatureUnion -""" - -from predspot import (crime_mapping, dataset_preparation, feature_engineering, - ml_modelling, utilities) -from predspot.crime_mapping import (KDE, QuadratCount, create_gridhexagonal, - create_gridpoints, create_gridsquares) -from predspot.dataset_preparation import Dataset -from predspot.ml_modelling import PredictionPipeline -from predspot.utilities import PandasFeatureUnion - -__version__ = '0.2.0' - -__all__ = [ - 'Dataset', 'PredictionPipeline', 'PandasFeatureUnion', - 'KDE', 'QuadratCount', - 'create_gridpoints', 'create_gridhexagonal', 'create_gridsquares', - 'crime_mapping', 'dataset_preparation', 'feature_engineering', - 'ml_modelling', 'utilities', -] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..89b35b0 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,103 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "predspot" +dynamic = ["version"] +description = "Predicting crime hotspots with machine learning" +readme = "README.md" +license = "BSD-3-Clause" +license-files = ["LICENSE"] +requires-python = ">=3.10" +authors = [ + { name = "Adelson Araujo", email = "adelson.dias@gmail.com" }, +] +keywords = [ + "crime", "hotspots", "spatio-temporal", "kernel density estimation", + "geopandas", "scikit-learn", "forecasting", "criminology", +] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Science/Research", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Scientific/Engineering", + "Topic :: Scientific/Engineering :: GIS", + "Topic :: Scientific/Engineering :: Artificial Intelligence", +] +dependencies = [ + "numpy>=1.24", + "pandas>=2.2", + "geopandas>=1.0", + "shapely>=2.0", + "scipy>=1.10", + "scikit-learn>=1.3", + "statsmodels>=0.14", + "matplotlib>=3.7", +] + +[project.optional-dependencies] +contour = ["geojsoncontour>=0.4"] +docs = [ + "mkdocs>=1.6,<2", + "mkdocs-material>=9.5,<10", + "mkdocstrings[python]>=0.26", + "mkdocs-autorefs>=1.2", + "mkdocs-jupyter>=0.25", +] +dev = [ + "pytest>=8", + "pytest-cov>=5", + "ruff>=0.6", + "build>=1.2", + "twine>=5", +] + +[project.urls] +Homepage = "https://github.com/adaj/predspot" +Documentation = "https://adaj.github.io/predspot/" +Repository = "https://github.com/adaj/predspot" +Issues = "https://github.com/adaj/predspot/issues" +Changelog = "https://github.com/adaj/predspot/blob/master/CHANGELOG.md" + +[tool.setuptools] +package-dir = { "" = "src" } + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools.dynamic] +version = { attr = "predspot.__version__" } + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-ra" +filterwarnings = [ + "error::DeprecationWarning:predspot", + "error::FutureWarning:predspot", +] + +[tool.coverage.run] +source = ["predspot"] +branch = true + +[tool.ruff] +line-length = 100 +exclude = ["examples"] # tutorial code: readability over line length +target-version = "py310" +src = ["src", "tests"] + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "B", "UP", "NPY", "PD"] +ignore = [ + "PD011", # .values is used deliberately for numpy interop +] + +[tool.ruff.lint.per-file-ignores] +"tests/*" = ["B011"] diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 331ba9b..0000000 --- a/requirements.txt +++ /dev/null @@ -1,8 +0,0 @@ -numpy>=1.24 -pandas>=2.2 -geopandas>=1.0 -shapely>=2.0 -scipy>=1.10 -scikit-learn>=1.3 -statsmodels>=0.14 -matplotlib>=3.7 diff --git a/setup.py b/setup.py deleted file mode 100644 index b41b761..0000000 --- a/setup.py +++ /dev/null @@ -1,27 +0,0 @@ -from setuptools import setup - -setup( - name='predspot', - version='0.2.0', - description="Predicting crime hotspots with machine learning", - url='https://github.com/adaj/predspot', - author="Adelson Araujo", - author_email='adelson.dias@gmail.com', - packages=['predspot'], - install_requires=[ - 'numpy>=1.24', - 'pandas>=2.2', - 'geopandas>=1.0', - 'shapely>=2.0', - 'scipy>=1.10', - 'scikit-learn>=1.3', - 'statsmodels>=0.14', - 'matplotlib>=3.7', - ], - extras_require={'contour': ['geojsoncontour']}, - classifiers=[ - 'Intended Audience :: Science/Research', - 'License :: BSD 3-Clause License' - ], - python_requires='>=3.10', -) diff --git a/src/predspot/__init__.py b/src/predspot/__init__.py new file mode 100644 index 0000000..5d2ca3d --- /dev/null +++ b/src/predspot/__init__.py @@ -0,0 +1,54 @@ +""" +Predspot — predicting crime hotspots with machine learning. + +Typical use:: + + from predspot import Dataset, PredictionPipeline + from predspot.crime_mapping import KDE, create_gridpoints + from predspot.feature_engineering import Seasonality, Trend, Diff + from predspot.utilities import PandasFeatureUnion +""" + +from predspot import ( + crime_mapping, + dataset_preparation, + feature_engineering, + ml_modelling, + synthetic, + utilities, +) +from predspot.crime_mapping import ( + KDE, + QuadratCount, + create_gridhexagonal, + create_gridpoints, + create_gridsquares, + get_city_shape, + load_study_area, +) +from predspot.dataset_preparation import Dataset +from predspot.ml_modelling import PredictionPipeline +from predspot.synthetic import generate_crimes +from predspot.utilities import PandasFeatureUnion + +__version__ = "0.2.0" + +__all__ = [ + "Dataset", + "PredictionPipeline", + "PandasFeatureUnion", + "KDE", + "QuadratCount", + "create_gridpoints", + "create_gridhexagonal", + "create_gridsquares", + "load_study_area", + "get_city_shape", + "generate_crimes", + "synthetic", + "crime_mapping", + "dataset_preparation", + "feature_engineering", + "ml_modelling", + "utilities", +] diff --git a/predspot/crime_mapping.py b/src/predspot/crime_mapping.py similarity index 59% rename from predspot/crime_mapping.py rename to src/predspot/crime_mapping.py index 623593f..f2ccfa9 100644 --- a/predspot/crime_mapping.py +++ b/src/predspot/crime_mapping.py @@ -6,17 +6,24 @@ timestamped crime events into a *spatio-temporal series*: a value per grid cell per time period. Two families of mapping are available: -* :class:`KDE` — kernel density estimation evaluated on a grid of **points** - (see :func:`create_gridpoints`). This is the default approach of Predspot. -* :class:`QuadratCount` — plain event counts per **cell** of a polygonal grid - (see :func:`create_gridhexagonal` and :func:`create_gridsquares`). - -Both produce the same output format, a :class:`pandas.Series` named +* [`KDE`][predspot.crime_mapping.KDE] — kernel density estimation evaluated on a grid of **points** + (see [`create_gridpoints`][predspot.crime_mapping.create_gridpoints]). This is the default + approach of Predspot. +* [`QuadratCount`][predspot.crime_mapping.QuadratCount] — plain event counts per **cell** of a +polygonal grid + (see [`create_gridhexagonal`][predspot.crime_mapping.create_gridhexagonal] and + [`create_gridsquares`][predspot.crime_mapping.create_gridsquares]). + +Both produce the same output format, a ``pandas.Series`` named ``crime_density`` indexed by ``(t, places)``, so they are interchangeable -inside :class:`predspot.ml_modelling.PredictionPipeline`. +inside [`PredictionPipeline`][predspot.ml_modelling.PredictionPipeline]. + +The study area itself can be fetched from OpenStreetMap with +[`load_study_area`][predspot.crime_mapping.load_study_area] (requires the optional ``osmnx`` +dependency). """ -__author__ = 'Adelson Araujo' +__author__ = "Adelson Araujo" import logging import math @@ -26,7 +33,7 @@ import numpy as np import pandas as pd from scipy.stats import gaussian_kde -from shapely.geometry import Point, Polygon +from shapely.geometry import Polygon from sklearn.base import BaseEstimator, TransformerMixin logger = logging.getLogger(__name__) @@ -41,9 +48,16 @@ # Public aliases accepted for the time frequency and the pandas offset alias # they map to. Old pandas used 'M' for month end; pandas >= 2.2 uses 'ME'. TFREQ_ALIASES = { - 'M': 'ME', 'ME': 'ME', 'MONTH': 'ME', 'MONTHLY': 'ME', - 'W': 'W', 'WEEK': 'W', 'WEEKLY': 'W', - 'D': 'D', 'DAY': 'D', 'DAILY': 'D', + "M": "ME", + "ME": "ME", + "MONTH": "ME", + "MONTHLY": "ME", + "W": "W", + "WEEK": "W", + "WEEKLY": "W", + "D": "D", + "DAY": "D", + "DAILY": "D", } @@ -62,28 +76,105 @@ def normalize_tfreq(tfreq): """ key = str(tfreq).upper() if key not in TFREQ_ALIASES: - raise ValueError( - f"Invalid tfreq {tfreq!r}. Choose (M)onthly, (W)eekly or (D)aily.") + raise ValueError(f"Invalid tfreq {tfreq!r}. Choose (M)onthly, (W)eekly or (D)aily.") return TFREQ_ALIASES[key] def tfreq_offset(tfreq): - """Return the :class:`pandas.DateOffset` that advances one period of ``tfreq``.""" + """Return the ``pandas.DateOffset`` that advances one period of ``tfreq``.""" alias = normalize_tfreq(tfreq) - if alias == 'ME': + if alias == "ME": return pd.offsets.MonthEnd(1) - if alias == 'W': + if alias == "W": return pd.offsets.Week(1) return pd.offsets.Day(1) +def load_study_area(place, crs=WGS84, which_result=None): + """ + Fetch the boundary polygon of a place from OpenStreetMap. + + Uses `osmnx `_ (optional dependency: + ``pip install predspot[osm]``) to geocode ``place`` with Nominatim and + return its administrative boundary, ready to be used as the + ``study_area`` of [`Dataset`][predspot.Dataset] or as the ``bbox`` of the + ``create_grid*`` functions. + + Args: + place (str or list): Name of the place as Nominatim understands it, + e.g. ``"Natal, Rio Grande do Norte, Brazil"``. A list of names + returns one row per place. + crs (str or pyproj.CRS): CRS of the returned GeoDataFrame (default WGS84). + which_result (int, optional): Forwarded to + ``osmnx.geocode_to_gdf`` to pick a specific Nominatim match when + the first one is not the boundary you want. + + Returns: + GeoDataFrame: One row per place with ``name``, ``display_name``, + ``osm_type``, ``osm_id`` and a (Multi)Polygon ``geometry``. + + Raises: + ImportError: If ``osmnx`` is not installed. + ValueError: If Nominatim returns a point instead of a boundary + polygon for the query. + """ + try: + import osmnx as ox + except ImportError as exc: + raise ImportError( + "load_study_area requires the optional dependency `osmnx`: pip install predspot[osm]" + ) from exc + logger.debug("Geocoding study area %r with osmnx", place) + gdf = ox.geocode_to_gdf(place, which_result=which_result) + if not gdf.geom_type.isin(["Polygon", "MultiPolygon"]).all(): + bad = gdf.loc[~gdf.geom_type.isin(["Polygon", "MultiPolygon"]), "display_name"] + raise ValueError( + "OpenStreetMap returned a non-polygon geometry for " + f"{bad.tolist()}. Try a more specific query (e.g. add the state " + "and country) or a different `which_result`." + ) + columns = [c for c in ("name", "display_name", "osm_type", "osm_id") if c in gdf.columns] + gdf = gdf[columns + ["geometry"]].reset_index(drop=True) + return gdf.to_crs(crs) + + +def get_city_shape(place_query): + """ + Fetch the shape (polygon) of a city or region from OpenStreetMap. + + A thin wrapper around ``osmnx.geocode_to_gdf`` that returns the raw + Nominatim result. Prefer [`load_study_area`][predspot.crime_mapping.load_study_area] + when you want the result validated (polygon geometry, tidy columns). + Requires the optional ``osmnx`` dependency (``pip install predspot[osm]``). + + Args: + place_query (str): Name of the place, in a format Nominatim accepts, + e.g. ``"Natal, RN, Brazil"`` or ``"Rio Grande do Norte, Brazil"``. + + Returns: + GeoDataFrame: One row (or more, if the query is ambiguous) with the + place geometry (Polygon/MultiPolygon) in WGS84. + + Example: + >>> city = get_city_shape("Natal, RN, Brazil") + >>> city.geometry.iloc[0] # shapely Polygon/MultiPolygon + """ + try: + import osmnx as ox + except ImportError as exc: + raise ImportError( + "get_city_shape requires the optional dependency `osmnx`: pip install predspot[osm]" + ) from exc + return ox.geocode_to_gdf(place_query) + + def _check_bbox(bbox): if not isinstance(bbox, gpd.GeoDataFrame): - raise TypeError('bbox must be a geopandas GeoDataFrame.') + raise TypeError("bbox must be a geopandas GeoDataFrame.") if bbox.crs is None: raise ValueError('bbox must have a CRS (e.g. bbox.set_crs("EPSG:4326")).') if len(bbox) == 0: - raise ValueError('bbox is empty.') + raise ValueError("bbox is empty.") def _wgs84_bounds(bbox): @@ -93,11 +184,9 @@ def _wgs84_bounds(bbox): def _clip_to_bbox(grid, bbox): """Keep only grid rows that intersect ``bbox`` (both in the same CRS).""" - keep = gpd.sjoin(grid, bbox[['geometry']], how='inner', - predicate='intersects').index.unique() + keep = gpd.sjoin(grid, bbox[["geometry"]], how="inner", predicate="intersects").index.unique() if len(keep) == 0: - raise ValueError( - 'resolution too big/coarse. No cells intersect the study area.') + raise ValueError("resolution too big/coarse. No cells intersect the study area.") return grid.loc[grid.index.isin(keep)] @@ -106,8 +195,8 @@ def _add_centroid_lonlat(grid): projected = grid.geometry.to_crs(grid.estimate_utm_crs()) centroids = projected.centroid.to_crs(WGS84) grid = grid.copy() - grid['lon'] = centroids.x.values - grid['lat'] = centroids.y.values + grid["lon"] = centroids.x.values + grid["lat"] = centroids.y.values return grid @@ -115,7 +204,7 @@ def create_gridpoints(bbox, resolution, return_coords=False): """ Create a regular grid of points covering a study area. - This is the grid used by :class:`KDE`: the density is evaluated at each + This is the grid used by [`KDE`][predspot.crime_mapping.KDE]: the density is evaluated at each point. The grid is built in WGS84 with the requested spacing and then re-projected to the CRS of ``bbox``. @@ -131,9 +220,9 @@ def create_gridpoints(bbox, resolution, return_coords=False): ``return_coords`` is True, a tuple ``(gridpoints, lonv, latv)``. """ if resolution <= 0: - raise ValueError('resolution must be a positive number of kilometers.') + raise ValueError("resolution must be a positive number of kilometers.") _check_bbox(bbox) - logger.debug('Creating point grid with resolution %s km', resolution) + logger.debug("Creating point grid with resolution %s km", resolution) b_w, b_s, b_e, b_n = _wgs84_bounds(bbox) nlon = max(int(np.ceil((b_e - b_w) / (resolution / KM_PER_DEG_LON))), 2) @@ -141,10 +230,10 @@ def create_gridpoints(bbox, resolution, return_coords=False): lonv, latv = np.meshgrid(np.linspace(b_w, b_e, nlon), np.linspace(b_s, b_n, nlat)) lon, lat = lonv.ravel(), latv.ravel() gridpoints = gpd.GeoDataFrame( - {'lon': lon, 'lat': lat}, - geometry=gpd.points_from_xy(lon, lat), crs=WGS84).to_crs(bbox.crs) + {"lon": lon, "lat": lat}, geometry=gpd.points_from_xy(lon, lat), crs=WGS84 + ).to_crs(bbox.crs) gridpoints = _clip_to_bbox(gridpoints, bbox) - gridpoints.index.name = 'places' + gridpoints.index.name = "places" if return_coords: return gridpoints, lonv, latv return gridpoints @@ -163,10 +252,12 @@ def create_hexagon(side, x, y): Returns: Polygon: The hexagon. """ - return Polygon([ - (x + math.cos(math.radians(angle)) * side, - y + math.sin(math.radians(angle)) * side) - for angle in range(0, 360, 60)]) + return Polygon( + [ + (x + math.cos(math.radians(angle)) * side, y + math.sin(math.radians(angle)) * side) + for angle in range(0, 360, 60) + ] + ) def create_gridhexagonal(bbox, resolution): @@ -186,12 +277,12 @@ def create_gridhexagonal(bbox, resolution): ``lon`` and ``lat`` (centroid) columns and an index named ``places``. """ if resolution <= 0: - raise ValueError('resolution must be a positive number of kilometers.') + raise ValueError("resolution must be a positive number of kilometers.") _check_bbox(bbox) - logger.debug('Creating hexagonal grid with resolution %s km', resolution) + logger.debug("Creating hexagonal grid with resolution %s km", resolution) # Side length such that the hexagon area equals resolution**2. - side_km = math.sqrt(resolution ** 2 * 2 / (3 * math.sqrt(3))) + side_km = math.sqrt(resolution**2 * 2 / (3 * math.sqrt(3))) side = side_km / KM_PER_DEG_LAT # degrees (isotropic approximation) x_min, y_min, x_max, y_max = _wgs84_bounds(bbox) @@ -222,7 +313,7 @@ def create_gridhexagonal(bbox, resolution): grid = gpd.GeoDataFrame(geometry=hexagons, crs=WGS84).to_crs(bbox.crs) grid = _clip_to_bbox(grid, bbox) grid = _add_centroid_lonlat(grid) - grid.index.name = 'places' + grid.index.name = "places" return grid @@ -239,21 +330,22 @@ def create_gridsquares(bbox, resolution=1): ``lon`` and ``lat`` (centroid) columns and an index named ``places``. """ if resolution <= 0: - raise ValueError('resolution must be a positive number of kilometers.') + raise ValueError("resolution must be a positive number of kilometers.") _check_bbox(bbox) - logger.debug('Creating square grid with resolution %s km', resolution) + logger.debug("Creating square grid with resolution %s km", resolution) x0, y0, xf, yf = _wgs84_bounds(bbox) dx = resolution / KM_PER_DEG_LON dy = resolution / KM_PER_DEG_LAT xs = np.arange(x0, xf, dx) ys = np.arange(y0, yf, dy) - squares = [Polygon([(x, y), (x + dx, y), (x + dx, y + dy), (x, y + dy)]) - for x in xs for y in ys] + squares = [ + Polygon([(x, y), (x + dx, y), (x + dx, y + dy), (x, y + dy)]) for x in xs for y in ys + ] grid = gpd.GeoDataFrame(geometry=squares, crs=WGS84).to_crs(bbox.crs) grid = _clip_to_bbox(grid, bbox) grid = _add_centroid_lonlat(grid) - grid.index.name = 'places' + grid.index.name = "places" return grid @@ -261,8 +353,8 @@ class SpatioTemporalMapping(ABC, TransformerMixin, BaseEstimator): """ Abstract base class for spatio-temporal crime mapping. - Subclasses implement :meth:`fit_grid`, which maps the events of a single - time period onto the grid. :meth:`transform` takes care of splitting the + Subclasses implement ``fit_grid``, which maps the events of a single + time period onto the grid. ``transform`` takes care of splitting the events into periods, filling periods with no events and assembling the result into a series indexed by ``(t, places)``. @@ -284,15 +376,20 @@ def __init__(self, tfreq, grid, start_time=None, end_time=None): self.end_time = end_time self._tfreq = normalize_tfreq(tfreq) - missing = [c for c in ('geometry', 'lon', 'lat') if c not in grid.columns] + missing = [c for c in ("geometry", "lon", "lat") if c not in grid.columns] if missing: raise ValueError( - f'Input grid must have `geometry`, `lon` and `lat` columns; missing {missing}.') + f"Input grid must have `geometry`, `lon` and `lat` columns; missing {missing}." + ) self._grid = grid self._start_time = pd.to_datetime(start_time) if start_time else None self._end_time = pd.to_datetime(end_time) if end_time else None - logger.debug('%s initialised with tfreq=%s and %d places', - type(self).__name__, self._tfreq, len(grid)) + logger.debug( + "%s initialised with tfreq=%s and %d places", + type(self).__name__, + self._tfreq, + len(grid), + ) @abstractmethod def fit_grid(self, data_points): @@ -329,13 +426,13 @@ def transform(self, data_points): pandas.Series: Values named ``crime_density`` indexed by ``(t, places)``, sorted. """ - if 't' not in data_points.columns: - raise ValueError('data_points must have a `t` timestamp column.') - events = data_points.set_index(pd.DatetimeIndex(data_points['t'])).sort_index() + if "t" not in data_points.columns: + raise ValueError("data_points must have a `t` timestamp column.") + events = data_points.set_index(pd.DatetimeIndex(data_points["t"])).sort_index() chunks = {label: chunk for label, chunk in events.resample(self._tfreq)} labels = pd.DatetimeIndex(list(chunks.keys())) time_index = self._time_index(labels) - logger.debug('Mapping %d events over %d periods', len(events), len(time_index)) + logger.debug("Mapping %d events over %d periods", len(events), len(time_index)) zeros = dict.fromkeys(self._grid.index, 0.0) rows = [] @@ -347,9 +444,9 @@ def transform(self, data_points): rows.append(self.fit_grid(chunk)) frame = pd.DataFrame(rows, index=time_index) frame = frame.reindex(columns=self._grid.index) - stseries = frame.stack() - stseries.index.names = ['t', 'places'] - stseries.name = 'crime_density' + stseries = frame.stack() # noqa: PD013 - long format with (t, places) index + stseries.index.names = ["t", "places"] + stseries.name = "crime_density" return stseries.sort_index() @@ -363,28 +460,32 @@ class KDE(SpatioTemporalMapping): Args: tfreq (str): Time frequency (``'M'``, ``'W'`` or ``'D'``). - grid (GeoDataFrame): Point grid, see :func:`create_gridpoints`. - start_time, end_time: See :class:`SpatioTemporalMapping`. + grid (GeoDataFrame): Point grid, see + [`create_gridpoints`][predspot.crime_mapping.create_gridpoints]. + start_time (str or datetime, optional): See + [`SpatioTemporalMapping`][predspot.crime_mapping.SpatioTemporalMapping]. + end_time (str or datetime, optional): See + [`SpatioTemporalMapping`][predspot.crime_mapping.SpatioTemporalMapping]. bandwidth (str or float): ``'silverman'`` (default) or ``'scott'`` to estimate the bandwidth from the first period with enough events and keep it fixed afterwards (so densities are comparable across time), or a positive number used directly as the KDE factor. """ - def __init__(self, tfreq, grid, start_time=None, end_time=None, bandwidth='silverman'): + def __init__(self, tfreq, grid, start_time=None, end_time=None, bandwidth="silverman"): super().__init__(tfreq, grid, start_time, end_time) self.bandwidth = bandwidth if isinstance(bandwidth, str): method = bandwidth.lower() - if method == 'auto': - method = 'silverman' - if method not in ('silverman', 'scott'): + if method == "auto": + method = "silverman" + if method not in ("silverman", "scott"): raise ValueError("bandwidth must be 'silverman', 'scott' or a number.") self._bw_method = method self._factor = None else: if bandwidth <= 0: - raise ValueError('bandwidth must be a positive number.') + raise ValueError("bandwidth must be a positive number.") self._bw_method = None self._factor = float(bandwidth) self._kernel = None @@ -413,34 +514,41 @@ def fit_grid(self, data_points, as_df=False): self._kernel = gaussian_kde(xy, bw_method=bw) if self._factor is None: self._factor = float(self._kernel.factor) - logger.debug('KDE bandwidth factor estimated with %s: %.5f', - self._bw_method, self._factor) - values = self._kernel(self._grid[['lon', 'lat']].values.T) - density = pd.DataFrame({'crime_density': values}, index=self._grid.index) + logger.debug( + "KDE bandwidth factor estimated with %s: %.5f", self._bw_method, self._factor + ) + values = self._kernel(self._grid[["lon", "lat"]].values.T) + density = pd.DataFrame({"crime_density": values}, index=self._grid.index) if as_df: return density - return density['crime_density'].to_dict() + return density["crime_density"].to_dict() class QuadratCount(SpatioTemporalMapping): """ Count of crime events per grid cell (quadrat count). - An alternative to :class:`KDE` that works on polygonal grids (hexagons or - squares, see :func:`create_gridhexagonal` and :func:`create_gridsquares`): + An alternative to [`KDE`][predspot.crime_mapping.KDE] that works on polygonal grids (hexagons or + squares, see [`create_gridhexagonal`][predspot.crime_mapping.create_gridhexagonal] and + [`create_gridsquares`][predspot.crime_mapping.create_gridsquares]): the value of a cell in a period is the number of events that fall in it. Args: tfreq (str): Time frequency (``'M'``, ``'W'`` or ``'D'``). grid (GeoDataFrame): Polygonal grid. - start_time, end_time: See :class:`SpatioTemporalMapping`. + start_time (str or datetime, optional): See + [`SpatioTemporalMapping`][predspot.crime_mapping.SpatioTemporalMapping]. + end_time (str or datetime, optional): See + [`SpatioTemporalMapping`][predspot.crime_mapping.SpatioTemporalMapping]. """ def __init__(self, tfreq, grid, start_time=None, end_time=None): super().__init__(tfreq, grid, start_time, end_time) - if not grid.geom_type.isin(['Polygon', 'MultiPolygon']).all(): - raise ValueError('QuadratCount requires a polygonal grid ' - '(see create_gridhexagonal / create_gridsquares).') + if not grid.geom_type.isin(["Polygon", "MultiPolygon"]).all(): + raise ValueError( + "QuadratCount requires a polygonal grid " + "(see create_gridhexagonal / create_gridsquares)." + ) def fit_grid(self, data_points, as_df=False): """ @@ -453,15 +561,15 @@ def fit_grid(self, data_points, as_df=False): Returns: dict or DataFrame: Number of events per cell. """ - points = data_points[['geometry']].to_crs(self._grid.crs) - joined = gpd.sjoin(points, self._grid[['geometry']], how='inner', - predicate='within') + points = data_points[["geometry"]].to_crs(self._grid.crs) + joined = gpd.sjoin(points, self._grid[["geometry"]], how="inner", predicate="within") # The right index column is named after the grid index ('places'); # fall back to geopandas' default name otherwise. - col = 'places' if 'places' in joined.columns else 'index_right' + col = "places" if "places" in joined.columns else "index_right" counts = joined.groupby(col).size().reindex(self._grid.index, fill_value=0) - density = pd.DataFrame({'crime_density': counts.astype(float).values}, - index=self._grid.index) + density = pd.DataFrame( + {"crime_density": counts.astype(float).values}, index=self._grid.index + ) if as_df: return density - return density['crime_density'].to_dict() + return density["crime_density"].to_dict() diff --git a/predspot/dataset_preparation.py b/src/predspot/dataset_preparation.py similarity index 64% rename from predspot/dataset_preparation.py rename to src/predspot/dataset_preparation.py index 4f91cd3..bfd5140 100644 --- a/predspot/dataset_preparation.py +++ b/src/predspot/dataset_preparation.py @@ -3,11 +3,12 @@ ========================== Prepares crime event data together with the study area it belongs to. -:class:`Dataset` validates the input, converts the events to a GeoDataFrame +[`Dataset`][predspot.dataset_preparation.Dataset] validates the input, converts the events to a +GeoDataFrame of points in WGS84 and offers simple plotting and splitting helpers. """ -__author__ = 'Adelson Araujo' +__author__ = "Adelson Araujo" import logging @@ -17,7 +18,7 @@ logger = logging.getLogger(__name__) WGS84 = "EPSG:4326" -REQUIRED_COLUMNS = ('tag', 't', 'lon', 'lat') +REQUIRED_COLUMNS = ("tag", "t", "lon", "lat") class Dataset: @@ -27,7 +28,7 @@ class Dataset: Args: crimes (pandas.DataFrame): Crime events with at least the columns ``tag`` (crime type), ``t`` (timestamp, anything - :func:`pandas.to_datetime` understands), ``lon`` and ``lat`` + ``pandas.to_datetime`` understands), ``lon`` and ``lat`` (WGS84 degrees). The input is **not** modified. study_area (geopandas.GeoDataFrame): Boundary of the study area. It must have a CRS set. @@ -40,17 +41,20 @@ class Dataset: def __init__(self, crimes, study_area): if not isinstance(study_area, gpd.GeoDataFrame): - raise TypeError('study_area must be a geopandas GeoDataFrame.') + raise TypeError("study_area must be a geopandas GeoDataFrame.") if study_area.crs is None: - raise ValueError('study_area must have a CRS set ' - '(e.g. study_area.set_crs("EPSG:4326")).') + raise ValueError( + 'study_area must have a CRS set (e.g. study_area.set_crs("EPSG:4326")).' + ) if not isinstance(crimes, pd.DataFrame): - raise TypeError('crimes must be a pandas DataFrame.') + raise TypeError("crimes must be a pandas DataFrame.") missing = [c for c in REQUIRED_COLUMNS if c not in crimes.columns] if missing: - raise ValueError('Input crime data must have at least `tag`, `t`, ' - f'`lon` and `lat` as columns; missing {missing}.') - logger.debug('Preparing dataset with %d crime events', len(crimes)) + raise ValueError( + "Input crime data must have at least `tag`, `t`, " + f"`lon` and `lat` as columns; missing {missing}." + ) + logger.debug("Preparing dataset with %d crime events", len(crimes)) self._study_area = study_area if isinstance(crimes, gpd.GeoDataFrame) and crimes.crs is not None: @@ -58,19 +62,22 @@ def __init__(self, crimes, study_area): else: events = crimes.copy() events = gpd.GeoDataFrame( - events.drop(columns=['geometry'], errors='ignore'), - geometry=gpd.points_from_xy(events['lon'], events['lat']), - crs=WGS84) - events['t'] = pd.to_datetime(events['t']) + events.drop(columns=["geometry"], errors="ignore"), + geometry=gpd.points_from_xy(events["lon"], events["lat"]), + crs=WGS84, + ) + events["t"] = pd.to_datetime(events["t"]) self._crimes = events def __repr__(self): - counts = self._crimes['tag'].value_counts().to_dict() - return ('predspot.Dataset<\n' - f' crimes = GeoDataFrame({self._crimes.shape[0]}),\n' - f' >> {counts}\n' - f' study_area = GeoDataFrame({self._study_area.shape[0]}),\n' - '>') + counts = self._crimes["tag"].value_counts().to_dict() + return ( + "predspot.Dataset<\n" + f" crimes = GeoDataFrame({self._crimes.shape[0]}),\n" + f" >> {counts}\n" + f" study_area = GeoDataFrame({self._study_area.shape[0]}),\n" + ">" + ) @property def crimes(self): @@ -85,8 +92,7 @@ def study_area(self): @property def shape(self): """dict: Shapes of ``crimes`` and ``study_area``.""" - return {'crimes': self._crimes.shape, - 'study_area': self._study_area.shape} + return {"crimes": self._crimes.shape, "study_area": self._study_area.shape} def plot(self, ax=None, crime_samples=1000, **kwargs): """ @@ -95,19 +101,19 @@ def plot(self, ax=None, crime_samples=1000, **kwargs): Args: ax (matplotlib.axes.Axes, optional): Axes to draw on. crime_samples (int): Number of events to draw (random sample). - **kwargs: ``study_area=dict(...)`` and ``crimes=dict(...)`` are + **kwargs (dict): ``study_area=dict(...)`` and ``crimes=dict(...)`` are forwarded to the respective ``GeoDataFrame.plot`` calls. Returns: matplotlib.axes.Axes: The axes drawn on. """ - area_kwargs = {'color': 'white', 'edgecolor': 'black'} - area_kwargs.update(kwargs.pop('study_area', {})) + area_kwargs = {"color": "white", "edgecolor": "black"} + area_kwargs.update(kwargs.pop("study_area", {})) study_area = self.study_area.to_crs(WGS84) ax = study_area.plot(ax=ax, **area_kwargs) n = min(crime_samples, len(self.crimes)) - crimes_kwargs = {'marker': 'x'} - crimes_kwargs.update(kwargs.pop('crimes', {})) + crimes_kwargs = {"marker": "x"} + crimes_kwargs.update(kwargs.pop("crimes", {})) self.crimes.sample(n).plot(ax=ax, **crimes_kwargs) return ax @@ -123,8 +129,8 @@ def train_test_split(self, test_size=0.25, random_state=None): tuple: ``(train_dataset, test_dataset)``. """ if not 0 < test_size < 1: - raise ValueError('test_size must be between 0 and 1.') + raise ValueError("test_size must be between 0 and 1.") test = self.crimes.sample(frac=test_size, random_state=random_state) train = self.crimes.drop(index=test.index) - logger.debug('Split dataset: train=%d, test=%d', len(train), len(test)) + logger.debug("Split dataset: train=%d, test=%d", len(train), len(test)) return Dataset(train, self.study_area), Dataset(test, self.study_area) diff --git a/predspot/feature_engineering.py b/src/predspot/feature_engineering.py similarity index 72% rename from predspot/feature_engineering.py rename to src/predspot/feature_engineering.py index 5cfa02c..43f737b 100644 --- a/predspot/feature_engineering.py +++ b/src/predspot/feature_engineering.py @@ -3,20 +3,21 @@ ========================== Turns the spatio-temporal series produced by a mapping (see -:mod:`predspot.crime_mapping`) into lagged features, one row per +[`crime_mapping`][predspot.crime_mapping]) into lagged features, one row per ``(t, places)``. Each feature class applies a time series transformation to the history of every place and then builds ``lags`` lagged columns from it: -* :class:`AR` — the raw series (autoregressive features); -* :class:`Diff` — first differences; -* :class:`Seasonality` — the seasonal component of an STL decomposition; -* :class:`Trend` — the trend component of an STL decomposition. +* [`AR`][predspot.feature_engineering.AR] — the raw series (autoregressive features); +* [`Diff`][predspot.feature_engineering.Diff] — first differences; +* [`Seasonality`][predspot.feature_engineering.Seasonality] — the seasonal component of an STL +decomposition; +* [`Trend`][predspot.feature_engineering.Trend] — the trend component of an STL decomposition. The output always contains one extra row for the period right after the last observed one, so that the fitted model can forecast the next period. """ -__author__ = 'Adelson Araujo' +__author__ = "Adelson Araujo" import logging from abc import abstractmethod @@ -32,7 +33,7 @@ def infer_offset(time_index): """ - Infer the :class:`pandas.DateOffset` between consecutive periods. + Infer the ``pandas.DateOffset`` between consecutive periods. Args: time_index (pandas.DatetimeIndex): Unique, sorted period labels. @@ -47,8 +48,9 @@ def infer_offset(time_index): time_index = pd.DatetimeIndex(time_index).unique().sort_values() freq = pd.infer_freq(time_index) if len(time_index) >= 3 else None if freq is None: - raise ValueError('Could not infer the time frequency of the series; ' - 'pass `tfreq` explicitly.') + raise ValueError( + "Could not infer the time frequency of the series; pass `tfreq` explicitly." + ) return pd.tseries.frequencies.to_offset(freq) @@ -64,7 +66,7 @@ class TimeSeriesFeatures(BaseEstimator, TransformerMixin): def __init__(self, lags, tfreq=None): if not isinstance(lags, int) or lags < 2: - raise ValueError('`lags` must be an integer greater than 1.') + raise ValueError("`lags` must be an integer greater than 1.") self.lags = lags self.tfreq = tfreq self._offset = tfreq_offset(tfreq) if tfreq is not None else None @@ -72,7 +74,7 @@ def __init__(self, lags, tfreq=None): @property def label(self): """str: Prefix of the feature columns (override in subclasses).""" - return 'feature' + return "feature" @abstractmethod def apply_ts_decomposition(self, ts): @@ -102,10 +104,10 @@ def make_lag_df(self, ts): original series restricted to the same index. """ if len(ts) <= self.lags: - raise ValueError('`lags` is higher than the number of time periods.') + raise ValueError("`lags` is higher than the number of time periods.") lag_df = pd.concat([ts.shift(lag) for lag in range(1, self.lags + 1)], axis=1) - lag_df = lag_df.iloc[self.lags:] - lag_df.columns = [f'{self.label}_{i}' for i in range(1, self.lags + 1)] + lag_df = lag_df.iloc[self.lags :] + lag_df.columns = [f"{self.label}_{i}" for i in range(1, self.lags + 1)] return lag_df, ts.loc[lag_df.index] def transform(self, stseries): @@ -119,21 +121,22 @@ def transform(self, stseries): pandas.DataFrame: Features indexed by ``(t, places)``, including one row for the period after the last observed one. """ - times = stseries.index.get_level_values('t') + times = stseries.index.get_level_values("t") offset = self._offset if self._offset is not None else infer_offset(times) - places = stseries.index.get_level_values('places').unique() - logger.debug('%s: computing %d lags for %d places', - type(self).__name__, self.lags, len(places)) + places = stseries.index.get_level_values("places").unique() + logger.debug( + "%s: computing %d lags for %d places", type(self).__name__, self.lags, len(places) + ) frames = [] for place in places: - ts = stseries.xs(place, level='places').sort_index() + ts = stseries.xs(place, level="places").sort_index() ts = self.apply_ts_decomposition(ts) ts.loc[ts.index[-1] + offset] = None # next period, to be forecast f, _ = self.make_lag_df(ts) - f['places'] = place - frames.append(f.set_index('places', append=True)) + f["places"] = place + frames.append(f.set_index("places", append=True)) X = pd.concat(frames) - X.index.names = ['t', 'places'] + X.index.names = ["t", "places"] return X.sort_index() @@ -142,7 +145,7 @@ class AR(TimeSeriesFeatures): @property def label(self): - return 'ar' + return "ar" def apply_ts_decomposition(self, ts): return ts @@ -153,7 +156,7 @@ class Diff(TimeSeriesFeatures): @property def label(self): - return 'diff' + return "diff" def apply_ts_decomposition(self, ts): return ts.diff().iloc[1:] @@ -166,8 +169,10 @@ class _STLFeatures(TimeSeriesFeatures): def apply_ts_decomposition(self, ts): if len(ts) < 2 * self.lags: - raise ValueError(f'{type(self).__name__} needs at least 2 * lags ' - f'({2 * self.lags}) periods; got {len(ts)}.') + raise ValueError( + f"{type(self).__name__} needs at least 2 * lags " + f"({2 * self.lags}) periods; got {len(ts)}." + ) result = STL(ts, period=self.lags).fit() return getattr(result, self.component) @@ -175,21 +180,21 @@ def apply_ts_decomposition(self, ts): class Seasonality(_STLFeatures): """Lags of the seasonal component of an STL decomposition (period = lags).""" - component = 'seasonal' + component = "seasonal" @property def label(self): - return 'seasonal' + return "seasonal" class Trend(_STLFeatures): """Lags of the trend component of an STL decomposition (period = lags).""" - component = 'trend' + component = "trend" @property def label(self): - return 'trend' + return "trend" class FeatureScaling(TransformerMixin, BaseEstimator): @@ -197,7 +202,7 @@ class FeatureScaling(TransformerMixin, BaseEstimator): Wrap a scikit-learn scaler so that it returns DataFrames. Args: - estimator: Any scikit-learn transformer (e.g. ``QuantileTransformer``). + estimator (TransformerMixin): Any scikit-learn transformer (e.g. ``QuantileTransformer``). """ def __init__(self, estimator): @@ -209,8 +214,7 @@ def fit(self, x, y=None): return self def __sklearn_is_fitted__(self): - return getattr(self, 'is_fitted_', False) + return getattr(self, "is_fitted_", False) def transform(self, x): - return pd.DataFrame(self.estimator.transform(x), - index=x.index, columns=x.columns) + return pd.DataFrame(self.estimator.transform(x), index=x.index, columns=x.columns) diff --git a/predspot/ml_modelling.py b/src/predspot/ml_modelling.py similarity index 63% rename from predspot/ml_modelling.py rename to src/predspot/ml_modelling.py index 738e567..32c6e6c 100644 --- a/predspot/ml_modelling.py +++ b/src/predspot/ml_modelling.py @@ -7,7 +7,7 @@ attached to their ``(t, places)`` labels. """ -__author__ = 'Adelson Araujo' +__author__ = "Adelson Araujo" import logging @@ -22,7 +22,7 @@ idx = pd.IndexSlice -SCORERS = {'r2': r2_score, 'mse': mean_squared_error} +SCORERS = {"r2": r2_score, "mse": mean_squared_error} class FeatureSelection(TransformerMixin, BaseEstimator): @@ -30,7 +30,7 @@ class FeatureSelection(TransformerMixin, BaseEstimator): Wrap a scikit-learn feature selector so that it returns DataFrames. Args: - estimator: A selector exposing ``support_`` after fit (e.g. ``RFE``). + estimator (object): A selector exposing ``support_`` after fit (e.g. ``RFE``). """ def __init__(self, estimator): @@ -42,7 +42,7 @@ def fit(self, x, y=None): return self def __sklearn_is_fitted__(self): - return getattr(self, 'is_fitted_', False) + return getattr(self, "is_fitted_", False) @property def support_(self): @@ -50,8 +50,9 @@ def support_(self): return self.estimator.support_ def transform(self, x): - return pd.DataFrame(self.estimator.transform(x), index=x.index, - columns=x.columns[self.estimator.support_]) + return pd.DataFrame( + self.estimator.transform(x), index=x.index, columns=x.columns[self.estimator.support_] + ) class Model(RegressorMixin, BaseEstimator): @@ -59,7 +60,7 @@ class Model(RegressorMixin, BaseEstimator): Wrap a scikit-learn regressor so that predictions come back as DataFrames. Args: - estimator: Any scikit-learn regressor. + estimator (sklearn.base.RegressorMixin): Any scikit-learn regressor. """ def __init__(self, estimator): @@ -71,15 +72,14 @@ def fit(self, x, y=None): return self def __sklearn_is_fitted__(self): - return getattr(self, 'is_fitted_', False) + return getattr(self, "is_fitted_", False) @property def feature_importances_(self): return self.estimator.feature_importances_ def predict(self, x): - return pd.DataFrame(self.estimator.predict(x), index=x.index, - columns=['crime_density']) + return pd.DataFrame(self.estimator.predict(x), index=x.index, columns=["crime_density"]) class PredictionPipeline(RegressorMixin, BaseEstimator): @@ -87,18 +87,19 @@ class PredictionPipeline(RegressorMixin, BaseEstimator): End-to-end crime hotspot prediction. The pipeline chains three stages: a spatio-temporal ``mapping`` (e.g. - :class:`predspot.crime_mapping.KDE`) that turns events into a series per + [`KDE`][predspot.crime_mapping.KDE]) that turns events into a series per place and period; a feature extraction step (e.g. - :class:`predspot.utilities.PandasFeatureUnion` of lag features) and a + [`PandasFeatureUnion`][predspot.utilities.PandasFeatureUnion] of lag features) and a scikit-learn ``estimator`` (or ``Pipeline``) that learns to predict the next period's value from the features. Args: - mapping: A :class:`predspot.crime_mapping.SpatioTemporalMapping`. - fextraction: A transformer taking the series and returning features. - estimator: A scikit-learn regressor or ``Pipeline`` whose last step + mapping (SpatioTemporalMapping): A + [`SpatioTemporalMapping`][predspot.crime_mapping.SpatioTemporalMapping]. + fextraction (object): A transformer taking the series and returning features. + estimator (object): A scikit-learn regressor or ``Pipeline`` whose last step returns a DataFrame with a ``crime_density`` column (see - :class:`Model`). + [`Model`][predspot.ml_modelling.Model]). random_state (int, optional): Seed used to shuffle the training rows. """ @@ -140,7 +141,7 @@ def next_time(self): def _check_fitted(self): if self._X is None: - raise RuntimeError('This pipeline was not fitted yet.') + raise RuntimeError("This pipeline was not fitted yet.") @property def feature_importances(self): @@ -148,26 +149,28 @@ def feature_importances(self): Importance of each selected feature. Works when ``estimator`` is a ``Pipeline`` whose last step exposes - ``feature_importances_`` (e.g. :class:`Model` around a random - forest); an optional :class:`FeatureSelection` step before it is + ``feature_importances_`` (e.g. [`Model`][predspot.ml_modelling.Model] around a random + forest); an optional [`FeatureSelection`][predspot.ml_modelling.FeatureSelection] step + before it is taken into account. Returns: pandas.DataFrame: Importance per feature, sorted descending. """ self._check_fitted() - steps = getattr(self.estimator, 'steps', [('model', self.estimator)]) + steps = getattr(self.estimator, "steps", [("model", self.estimator)]) model = steps[-1][1] try: importances = model.feature_importances_ except AttributeError as exc: - raise AttributeError('The estimator does not expose feature_importances_.') from exc + raise AttributeError("The estimator does not expose feature_importances_.") from exc columns = self._X.columns for _, step in steps[:-1]: - if hasattr(step, 'support_'): + if hasattr(step, "support_"): columns = columns[step.support_] - return (pd.DataFrame({'importance': importances}, index=columns) - .sort_values('importance', ascending=False)) + return pd.DataFrame({"importance": importances}, index=columns).sort_values( + "importance", ascending=False + ) def fit(self, dataset, y=None): """ @@ -175,22 +178,22 @@ def fit(self, dataset, y=None): Args: dataset (predspot.Dataset): Crime events and study area. - y: Ignored; present for scikit-learn compatibility. + y (None): Ignored; present for scikit-learn compatibility. Returns: PredictionPipeline: ``self``. """ - logger.debug('Fitting prediction pipeline') + logger.debug("Fitting prediction pipeline") self._dataset = dataset self._stseries = self.mapping.fit_transform(dataset.crimes) self._X = self.fextraction.fit_transform(self._stseries) - t0 = self._X.index.get_level_values('t').min() - tf = self._stseries.index.get_level_values('t').max() + t0 = self._X.index.get_level_values("t").min() + tf = self._stseries.index.get_level_values("t").max() X = self._X.loc[t0:tf].sample(frac=1, random_state=self.random_state) y = self._stseries.loc[X.index] self.estimator.fit(X, y) - self._t_plus_one = self._X.index.get_level_values('t').max() - logger.debug('Pipeline fitted on %d rows; next period is %s', len(X), self._t_plus_one) + self._t_plus_one = self._X.index.get_level_values("t").max() + logger.debug("Pipeline fitted on %d rows; next period is %s", len(X), self._t_plus_one) return self def predict(self): @@ -207,48 +210,58 @@ def predict(self): self._check_fitted() X = self._X.loc[[self._t_plus_one], :] y_pred = pd.DataFrame(self.estimator.predict(X), index=X.index) - y_pred.columns = ['crime_density'] - logger.debug('Predicted %d places for %s', len(y_pred), self._t_plus_one) - self._stseries = pd.concat([self._stseries, y_pred['crime_density']]).sort_index() - self._stseries.name = 'crime_density' + y_pred.columns = ["crime_density"] + logger.debug("Predicted %d places for %s", len(y_pred), self._t_plus_one) + self._stseries = pd.concat([self._stseries, y_pred["crime_density"]]).sort_index() + self._stseries.name = "crime_density" self._X = self.fextraction.transform(self._stseries) self._t_plus_one = self._t_plus_one + self._offset return y_pred - def evaluate(self, scoring='r2', cv=5): + def evaluate(self, scoring="r2", cv=5): """ Score the estimator with time series cross-validation. Periods are split in ``cv`` consecutive folds - (:class:`sklearn.model_selection.TimeSeriesSplit`); the estimator is + (``sklearn.model_selection.TimeSeriesSplit``); the estimator is refitted on the original data afterwards. Args: - scoring (str): ``'r2'`` or ``'mse'``. + scoring (str or list): ``'r2'``, ``'mse'`` or a list of them. cv (int): Number of folds (must be lower than the number of periods). Returns: - list: One score per fold. + list or pandas.DataFrame: One score per fold; with a list of + scorings, a DataFrame with one column per scoring and one row + per fold. """ self._check_fitted() - if scoring not in SCORERS: + scorings = [scoring] if isinstance(scoring, str) else list(scoring) + unknown = [s for s in scorings if s not in SCORERS] + if unknown or not scorings: raise ValueError('invalid scoring. Try "r2" or "mse".') - scorer = SCORERS[scoring] - timestamps = (self._X.index.get_level_values('t').unique() - .intersection(self._stseries.index.get_level_values('t').unique()) - .sort_values()) + timestamps = ( + self._X.index.get_level_values("t") + .unique() + .intersection(self._stseries.index.get_level_values("t").unique()) + .sort_values() + ) if not isinstance(cv, int) or cv >= len(timestamps): - raise ValueError('cv must be an integer lower than the number of periods.') - scores = [] + raise ValueError("cv must be an integer lower than the number of periods.") + scores = {name: [] for name in scorings} for train_t, test_t in TimeSeriesSplit(cv).split(timestamps): - X_train = (self._X.loc[idx[timestamps[train_t], :], :] - .sample(frac=1, random_state=self.random_state)) + X_train = self._X.loc[idx[timestamps[train_t], :], :].sample( + frac=1, random_state=self.random_state + ) X_test = self._X.loc[idx[timestamps[test_t], :], :] y_train = self._stseries.loc[X_train.index] y_test = self._stseries.loc[X_test.index] self.estimator.fit(X_train, y_train) y_pred = self.estimator.predict(X_test) - scores.append(scorer(y_test, y_pred)) - logger.debug('%s-fold CV %s scores: %s', cv, scoring, scores) + for name in scorings: + scores[name].append(SCORERS[name](y_test, y_pred)) + logger.debug("%s-fold CV scores: %s", cv, scores) self.fit(self._dataset) # back to normal - return scores + if isinstance(scoring, str): + return scores[scoring] + return pd.DataFrame(scores, index=[f"fold {i + 1}" for i in range(cv)]) diff --git a/predspot/pipeline.py b/src/predspot/pipeline.py similarity index 59% rename from predspot/pipeline.py rename to src/predspot/pipeline.py index 589899d..77b8b04 100644 --- a/predspot/pipeline.py +++ b/src/predspot/pipeline.py @@ -11,7 +11,7 @@ >>> predictions, pipeline = run_prediction_pipeline(crimes, study_area, grid_resolution=1) """ -__author__ = 'Adelson Araujo' +__author__ = "Adelson Araujo" import logging @@ -25,6 +25,7 @@ from sklearn.preprocessing import QuantileTransformer from predspot import crime_mapping, dataset_preparation, feature_engineering, ml_modelling +from predspot.synthetic import generate_crimes from predspot.utilities import PandasFeatureUnion logger = logging.getLogger(__name__) @@ -35,7 +36,10 @@ def generate_testdata(n_points, start_time, end_time, bounds=DEFAULT_BOUNDS, seed=None): """ - Generate uniformly random crime events inside a rectangular study area. + Generate synthetic crime events inside a rectangular study area. + + A thin wrapper around [`generate_crimes`][predspot.synthetic.generate_crimes] with + three hotspots and the default temporal patterns. Args: n_points (int): Number of events. @@ -48,26 +52,19 @@ def generate_testdata(n_points, start_time, end_time, bounds=DEFAULT_BOUNDS, see tuple: ``(crimes, study_area)`` — a DataFrame with ``tag``, ``t``, ``lon``, ``lat`` and a one-row GeoDataFrame with the study area. """ - rng = np.random.default_rng(seed) west, south, east, north = bounds - study_area = gpd.GeoDataFrame({'name': ['study_area']}, - geometry=[box(west, south, east, north)], crs='EPSG:4326') - start, end = pd.Timestamp(start_time), pd.Timestamp(end_time) - seconds = rng.integers(0, int((end - start).total_seconds()), n_points) - tags = rng.choice(['burglary', 'assault', 'drugs', 'homicide'], size=n_points, - p=np.array([1000, 100, 10, 1]) / 1111) - crimes = pd.DataFrame({ - 'tag': tags, - 't': start + pd.to_timedelta(seconds, unit='s'), - 'lon': rng.uniform(west, east, n_points), - 'lat': rng.uniform(south, north, n_points), - }) - logger.debug('Generated %d synthetic events', n_points) + study_area = gpd.GeoDataFrame( + {"name": ["study_area"]}, geometry=[box(west, south, east, north)], crs="EPSG:4326" + ) + crimes = generate_crimes( + study_area, n_events=n_points, start=start_time, end=end_time, seed=seed + ) return crimes, study_area -def build_default_pipeline(study_area, tfreq='M', grid_resolution=1, lags=2, - bandwidth='silverman', random_state=None): +def build_default_pipeline( + study_area, tfreq="M", grid_resolution=1, lags=2, bandwidth="silverman", random_state=None +): """ Build the default Predspot pipeline: KDE mapping, seasonal/trend/diff features, quantile scaling, RFE feature selection and a random forest. @@ -77,7 +74,7 @@ def build_default_pipeline(study_area, tfreq='M', grid_resolution=1, lags=2, tfreq (str): Time frequency (``'M'``, ``'W'`` or ``'D'``). grid_resolution (float): Grid spacing in kilometers. lags (int): Number of lags (and STL period) of the features. - bandwidth (str or float): KDE bandwidth, see :class:`predspot.crime_mapping.KDE`. + bandwidth (str or float): KDE bandwidth, see [`KDE`][predspot.crime_mapping.KDE]. random_state (int, optional): Seed for the estimator and shuffling. Returns: @@ -86,25 +83,49 @@ def build_default_pipeline(study_area, tfreq='M', grid_resolution=1, lags=2, grid = crime_mapping.create_gridpoints(study_area, grid_resolution) return ml_modelling.PredictionPipeline( mapping=crime_mapping.KDE(tfreq=tfreq, grid=grid, bandwidth=bandwidth), - fextraction=PandasFeatureUnion([ - ('seasonal', feature_engineering.Seasonality(lags=lags, tfreq=tfreq)), - ('trend', feature_engineering.Trend(lags=lags, tfreq=tfreq)), - ('diff', feature_engineering.Diff(lags=lags, tfreq=tfreq)), - ]), - estimator=Pipeline([ - ('f_scaling', feature_engineering.FeatureScaling( - QuantileTransformer(n_quantiles=10, output_distribution='uniform'))), - ('f_selection', ml_modelling.FeatureSelection( - RFE(RandomForestRegressor(n_estimators=20, random_state=random_state)))), - ('model', ml_modelling.Model( - RandomForestRegressor(n_estimators=50, random_state=random_state))), - ]), + fextraction=PandasFeatureUnion( + [ + ("seasonal", feature_engineering.Seasonality(lags=lags, tfreq=tfreq)), + ("trend", feature_engineering.Trend(lags=lags, tfreq=tfreq)), + ("diff", feature_engineering.Diff(lags=lags, tfreq=tfreq)), + ] + ), + estimator=Pipeline( + [ + ( + "f_scaling", + feature_engineering.FeatureScaling( + QuantileTransformer(n_quantiles=10, output_distribution="uniform") + ), + ), + ( + "f_selection", + ml_modelling.FeatureSelection( + RFE(RandomForestRegressor(n_estimators=20, random_state=random_state)) + ), + ), + ( + "model", + ml_modelling.Model( + RandomForestRegressor(n_estimators=50, random_state=random_state) + ), + ), + ] + ), random_state=random_state, ) -def run_prediction_pipeline(crime_data, study_area, crime_tags=None, time_range=None, - tfreq='M', grid_resolution=1, lags=2, random_state=None): +def run_prediction_pipeline( + crime_data, + study_area, + crime_tags=None, + time_range=None, + tfreq="M", + grid_resolution=1, + lags=2, + random_state=None, +): """ Fit the default pipeline on crime data and forecast the next period. @@ -121,28 +142,33 @@ def run_prediction_pipeline(crime_data, study_area, crime_tags=None, time_range= Returns: tuple: ``(predictions, pipeline)`` — the forecast for the next period - and the fitted :class:`predspot.ml_modelling.PredictionPipeline`. + and the fitted [`PredictionPipeline`][predspot.ml_modelling.PredictionPipeline]. """ - missing = [c for c in ('tag', 't', 'lat', 'lon') if c not in crime_data.columns] + missing = [c for c in ("tag", "t", "lat", "lon") if c not in crime_data.columns] if missing: - raise ValueError(f'Crime data must contain columns tag, t, lat, lon; missing {missing}') + raise ValueError(f"Crime data must contain columns tag, t, lat, lon; missing {missing}") if crime_tags: - crime_data = crime_data.loc[crime_data['tag'].isin(crime_tags)] + crime_data = crime_data.loc[crime_data["tag"].isin(crime_tags)] if time_range: - time_ix = pd.DatetimeIndex(pd.to_datetime(crime_data['t'])) + time_ix = pd.DatetimeIndex(pd.to_datetime(crime_data["t"])) crime_data = crime_data.iloc[time_ix.indexer_between_time(time_range[0], time_range[1])] dataset = dataset_preparation.Dataset(crimes=crime_data, study_area=study_area) - pipeline = build_default_pipeline(study_area, tfreq=tfreq, grid_resolution=grid_resolution, - lags=lags, random_state=random_state) + pipeline = build_default_pipeline( + study_area, + tfreq=tfreq, + grid_resolution=grid_resolution, + lags=lags, + random_state=random_state, + ) pipeline.fit(dataset) predictions = pipeline.predict() return predictions, pipeline -def evaluate_pipeline(pipeline, scoring='r2', cv=5): +def evaluate_pipeline(pipeline, scoring="r2", cv=5): """ - Cross-validate a fitted pipeline; see :meth:`PredictionPipeline.evaluate`. + Cross-validate a fitted pipeline; see ``PredictionPipeline.evaluate``. Args: pipeline (PredictionPipeline): A fitted pipeline. @@ -153,5 +179,5 @@ def evaluate_pipeline(pipeline, scoring='r2', cv=5): list: One score per fold. """ scores = pipeline.evaluate(scoring=scoring, cv=cv) - logger.debug('Evaluation complete. Mean score: %.4f', np.mean(scores)) + logger.debug("Evaluation complete. Mean score: %.4f", np.mean(scores)) return scores diff --git a/src/predspot/synthetic.py b/src/predspot/synthetic.py new file mode 100644 index 0000000..73e2720 --- /dev/null +++ b/src/predspot/synthetic.py @@ -0,0 +1,343 @@ +""" +Synthetic Data Module +===================== + +Generates realistic-looking synthetic crime events inside a study area, so +that Predspot can be tried, demonstrated and tested without real data. + +The generator is a simple inhomogeneous space-time point process: + +* **Space** — a mixture of ``n_hotspots`` Gaussian hotspots (centres drawn + uniformly inside the study area) plus a uniform background. The fraction + of events that belong to hotspots is ``hotspot_share``. +* **Time** — an intensity built from a linear trend, an annual cycle, a + day-of-week profile and an hour-of-day profile. Timestamps are drawn by + thinning uniform candidates, which is exact for a fixed number of events. + +Example:: + + from predspot.synthetic import generate_crimes + crimes = generate_crimes(study_area, n_events=5000, seed=0) + dataset = Dataset(crimes, study_area) +""" + +__author__ = "Adelson Araujo" + +import logging +import math + +import geopandas as gpd +import numpy as np +import pandas as pd +import shapely + +from predspot.crime_mapping import KM_PER_DEG_LAT, KM_PER_DEG_LON, WGS84, _check_bbox + +logger = logging.getLogger(__name__) + +DEFAULT_TAGS = {"burglary": 0.45, "robbery": 0.30, "assault": 0.20, "homicide": 0.05} + +# Relative intensity Monday..Sunday (normalised to mean 1 internally). +DEFAULT_WEEKLY_PROFILE = (0.90, 0.85, 0.90, 0.95, 1.10, 1.30, 1.00) + +# Relative intensity by hour of day, 0..23: quiet early morning, busy evening. +DEFAULT_HOURLY_PROFILE = ( + 0.7, 0.5, 0.4, 0.3, 0.25, 0.3, 0.45, 0.7, 0.9, 1.0, 1.05, 1.1, + 1.1, 1.05, 1.1, 1.15, 1.25, 1.4, 1.55, 1.7, 1.75, 1.6, 1.3, 1.0, +) # fmt: skip + + +def _study_polygon(study_area): + """Return the union of the study area geometries as a WGS84 shapely geometry.""" + _check_bbox(study_area) + return study_area.to_crs(WGS84).geometry.union_all() + + +def sample_points_in_polygon(polygon, n, rng, max_iterations=1000): + """ + Draw ``n`` points uniformly inside a polygon by rejection sampling. + + Args: + polygon (shapely.Geometry): Polygon in WGS84. + n (int): Number of points. + rng (numpy.random.Generator): Random generator. + max_iterations (int): Safety cap on rejection rounds. + + Returns: + tuple: ``(lon, lat)`` arrays of length ``n``. + """ + minx, miny, maxx, maxy = polygon.bounds + lon = np.empty(0) + lat = np.empty(0) + fill = polygon.area / ((maxx - minx) * (maxy - miny)) if polygon.area > 0 else 1.0 + batch = int(max(n / max(fill, 1e-3) * 1.2, 64)) + for _ in range(max_iterations): + cx = rng.uniform(minx, maxx, batch) + cy = rng.uniform(miny, maxy, batch) + inside = shapely.contains_xy(polygon, cx, cy) + lon = np.concatenate([lon, cx[inside]]) + lat = np.concatenate([lat, cy[inside]]) + if len(lon) >= n: + return lon[:n], lat[:n] + raise RuntimeError("Could not sample enough points inside the study area.") + + +def sample_points_around(polygon, centers, sd_km, n_per_center, rng, max_iterations=1000): + """ + Draw points from Gaussian clouds around hotspot centres, kept inside the polygon. + + Args: + polygon (shapely.Geometry): Study area in WGS84. + centers (numpy.ndarray): ``(k, 2)`` array of ``(lon, lat)`` centres. + sd_km (float or array): Standard deviation of each cloud in km. + n_per_center (array): Number of points to draw per centre. + rng (numpy.random.Generator): Random generator. + max_iterations (int): Safety cap on rejection rounds. + + Returns: + tuple: ``(lon, lat)`` arrays. + """ + sd_km = np.broadcast_to(np.asarray(sd_km, dtype=float), (len(centers),)) + lons, lats = [], [] + for (c_lon, c_lat), sd, n in zip(centers, sd_km, n_per_center, strict=True): + if n == 0: + continue + sd_lat = sd / KM_PER_DEG_LAT + sd_lon = sd / (KM_PER_DEG_LON * max(math.cos(math.radians(c_lat)), 0.05)) + lon = np.empty(0) + lat = np.empty(0) + remaining = int(n) + for _ in range(max_iterations): + cx = rng.normal(c_lon, sd_lon, remaining * 2) + cy = rng.normal(c_lat, sd_lat, remaining * 2) + inside = shapely.contains_xy(polygon, cx, cy) + lon = np.concatenate([lon, cx[inside]]) + lat = np.concatenate([lat, cy[inside]]) + if len(lon) >= n: + break + remaining = int(n - len(lon)) + else: + raise RuntimeError("Could not sample enough hotspot points inside the study area.") + lons.append(lon[:n]) + lats.append(lat[:n]) + if not lons: + return np.empty(0), np.empty(0) + return np.concatenate(lons), np.concatenate(lats) + + +def temporal_intensity( + timestamps, + start, + end, + trend=0.0, + annual_amplitude=0.0, + annual_peak_month=1, + weekly_profile=None, + hourly_profile=None, +): + """ + Relative event intensity at each timestamp (mean around 1). + + Args: + timestamps (DatetimeIndex): Times to evaluate. + start (Timestamp): Start of the simulation, used for the trend. + end (Timestamp): End of the simulation, used for the trend. + trend (float): Relative change of the intensity from ``start`` to + ``end`` (``0.5`` means +50% at the end, ``-0.3`` means -30%). + annual_amplitude (float): Amplitude of the annual cosine (0-1). + annual_peak_month (int): Month (1-12) where the annual cycle peaks. + weekly_profile (sequence): 7 relative weights, Monday to Sunday. + hourly_profile (sequence): 24 relative weights, hour 0 to 23. + + Returns: + numpy.ndarray: Intensity values, one per timestamp. + """ + timestamps = pd.DatetimeIndex(timestamps) + span = max((end - start).total_seconds(), 1.0) + frac = (timestamps - start).total_seconds() / span + lam = np.clip(1.0 + trend * frac, 0.0, None) + if annual_amplitude: + peak_doy = (annual_peak_month - 1) * 365.25 / 12 + 15 + lam = lam * ( + 1 + annual_amplitude * np.cos(2 * np.pi * (timestamps.dayofyear - peak_doy) / 365.25) + ) + if weekly_profile is not None: + weekly = np.asarray(weekly_profile, dtype=float) + if weekly.shape != (7,): + raise ValueError("weekly_profile must have 7 values (Monday..Sunday).") + lam = lam * (weekly / weekly.mean())[timestamps.dayofweek] + if hourly_profile is not None: + hourly = np.asarray(hourly_profile, dtype=float) + if hourly.shape != (24,): + raise ValueError("hourly_profile must have 24 values (hour 0..23).") + lam = lam * (hourly / hourly.mean())[timestamps.hour] + return np.asarray(lam) + + +def sample_timestamps(n, start, end, rng, max_iterations=1000, **intensity_kwargs): + """ + Draw ``n`` timestamps from an inhomogeneous process by thinning. + + Args: + n (int): Number of timestamps. + start (Timestamp): Start of the simulation. + end (Timestamp): End of the simulation. + rng (numpy.random.Generator): Random generator. + max_iterations (int): Safety cap on thinning rounds. + **intensity_kwargs (dict): Forwarded to + [`temporal_intensity`][predspot.synthetic.temporal_intensity]. + + Returns: + pandas.DatetimeIndex: ``n`` timestamps, unsorted. + """ + span = (end - start).total_seconds() + if span <= 0: + raise ValueError("end must be after start.") + # Upper bound of the intensity: product of the maxima of each factor. + lam_max = max(1.0, 1.0 + intensity_kwargs.get("trend", 0.0)) + lam_max *= 1 + abs(intensity_kwargs.get("annual_amplitude", 0.0)) + for key, size in (("weekly_profile", 7), ("hourly_profile", 24)): + profile = intensity_kwargs.get(key) + if profile is not None: + profile = np.asarray(profile, dtype=float) + if profile.shape != (size,): + raise ValueError(f"{key} must have {size} values.") + lam_max *= profile.max() / profile.mean() + accepted = [] + total = 0 + batch = int(max(n * lam_max * 1.2, 64)) + for _ in range(max_iterations): + candidates = start + pd.to_timedelta(rng.uniform(0, span, batch), unit="s") + lam = temporal_intensity(candidates, start, end, **intensity_kwargs) + keep = rng.uniform(0, lam_max, batch) < lam + accepted.append(candidates[keep]) + total += int(keep.sum()) + if total >= n: + break + else: + raise RuntimeError("Could not sample enough timestamps.") + stamps = accepted[0].append(accepted[1:]) if len(accepted) > 1 else accepted[0] + return stamps[:n].floor("s") + + +def generate_crimes( + study_area, + n_events=5000, + start="2019-01-01", + end="2020-12-31", + n_hotspots=3, + hotspot_share=0.7, + hotspot_sd_km=0.5, + tags=None, + trend=0.0, + annual_amplitude=0.2, + annual_peak_month=1, + weekly_profile=DEFAULT_WEEKLY_PROFILE, + hourly_profile=DEFAULT_HOURLY_PROFILE, + seed=None, + return_hotspots=False, +): + """ + Generate synthetic crime events inside a study area. + + Args: + study_area (GeoDataFrame): Boundary of the study area (any CRS). See + [`load_study_area`][predspot.crime_mapping.load_study_area] to fetch one from + OpenStreetMap. + n_events (int): Number of events to generate. + start (str or Timestamp): First possible timestamp. + end (str or Timestamp): Last possible timestamp. + n_hotspots (int): Number of Gaussian hotspots (0 for a uniform map). + hotspot_share (float): Fraction of events that belong to hotspots; + the rest is uniform background (0-1). + hotspot_sd_km (float or sequence): Standard deviation of the hotspot + clouds in km (one value or one per hotspot). + tags (dict or sequence): Crime types. A dict maps type to relative + weight; a sequence gives equal weights. Defaults to + ``DEFAULT_TAGS``. + trend (float): Relative change of the event rate from ``start`` to + ``end`` (``0.5`` = +50%). + annual_amplitude (float): Amplitude of the annual cycle (0 disables). + annual_peak_month (int): Month (1-12) where the annual cycle peaks. + weekly_profile (sequence or None): 7 weights Monday..Sunday + (``None`` disables the weekly pattern). + hourly_profile (sequence or None): 24 weights, hour 0..23 + (``None`` disables the hour-of-day pattern). + seed (int, optional): Seed for reproducibility. + return_hotspots (bool): Also return the hotspot centres. + + Returns: + pandas.DataFrame: Events with ``tag``, ``t``, ``lon``, ``lat`` columns + sorted by time, ready for [`Dataset`][predspot.Dataset]. If + ``return_hotspots`` is True, a tuple ``(crimes, hotspots)`` where + ``hotspots`` is a GeoDataFrame with the centre, ``sd_km`` and + ``share`` of each hotspot. + """ + if n_events <= 0: + raise ValueError("n_events must be positive.") + if not 0 <= hotspot_share <= 1: + raise ValueError("hotspot_share must be between 0 and 1.") + if n_hotspots < 0: + raise ValueError("n_hotspots must be >= 0.") + rng = np.random.default_rng(seed) + polygon = _study_polygon(study_area) + start, end = pd.Timestamp(start), pd.Timestamp(end) + + # --- tags --------------------------------------------------------------- + if tags is None: + tags = DEFAULT_TAGS + if isinstance(tags, dict): + names, weights = list(tags.keys()), np.asarray(list(tags.values()), dtype=float) + else: + names, weights = list(tags), np.ones(len(tags)) + if len(names) == 0 or (weights < 0).any() or weights.sum() == 0: + raise ValueError("tags must contain at least one type with a positive weight.") + tag_values = rng.choice(names, size=n_events, p=weights / weights.sum()) + + # --- space -------------------------------------------------------------- + if n_hotspots == 0: + n_hot = 0 + else: + n_hot = int(round(n_events * hotspot_share)) + n_bg = n_events - n_hot + hot_lon, hot_lat = sample_points_in_polygon(polygon, n_hotspots, rng) + centers = np.column_stack([hot_lon, hot_lat]) if n_hotspots else np.empty((0, 2)) + shares = rng.dirichlet(np.full(n_hotspots, 2.0)) if n_hotspots else np.empty(0) + per_center = rng.multinomial(n_hot, shares) if n_hot else np.zeros(n_hotspots, dtype=int) + sd_km = np.broadcast_to(np.asarray(hotspot_sd_km, dtype=float), (n_hotspots,)) + lon_h, lat_h = sample_points_around(polygon, centers, sd_km, per_center, rng) + lon_b, lat_b = ( + sample_points_in_polygon(polygon, n_bg, rng) if n_bg else (np.empty(0), np.empty(0)) + ) + lon = np.concatenate([lon_h, lon_b]) + lat = np.concatenate([lat_h, lat_b]) + order = rng.permutation(n_events) + lon, lat = lon[order], lat[order] + + # --- time --------------------------------------------------------------- + timestamps = sample_timestamps( + n_events, + start, + end, + rng, + trend=trend, + annual_amplitude=annual_amplitude, + annual_peak_month=annual_peak_month, + weekly_profile=weekly_profile, + hourly_profile=hourly_profile, + ) + + crimes = ( + pd.DataFrame({"tag": tag_values, "t": timestamps, "lon": lon, "lat": lat}) + .sort_values("t") + .reset_index(drop=True) + ) + logger.debug("Generated %d synthetic events with %d hotspots", n_events, n_hotspots) + if not return_hotspots: + return crimes + hotspots = gpd.GeoDataFrame( + {"sd_km": sd_km, "share": shares * hotspot_share}, + geometry=gpd.points_from_xy(centers[:, 0], centers[:, 1]), + crs=WGS84, + ) + return crimes, hotspots diff --git a/predspot/utilities.py b/src/predspot/utilities.py similarity index 76% rename from predspot/utilities.py rename to src/predspot/utilities.py index 6045f68..0033ddd 100644 --- a/predspot/utilities.py +++ b/src/predspot/utilities.py @@ -2,18 +2,18 @@ Utilities Module ================ -Helpers used across Predspot: a :class:`PandasFeatureUnion` that keeps +Helpers used across Predspot: a [`PandasFeatureUnion`][predspot.utilities.PandasFeatureUnion] that +keeps DataFrames (and their index) when combining feature transformers, and a GeoJSON contour export for density maps. """ -__author__ = 'Adelson Araujo' +__author__ = "Adelson Araujo" import logging import numpy as np import pandas as pd -from geopandas import GeoDataFrame from sklearn.base import BaseEstimator, TransformerMixin logger = logging.getLogger(__name__) @@ -23,7 +23,7 @@ class PandasFeatureUnion(TransformerMixin, BaseEstimator): """ Concatenate the DataFrame outputs of several transformers column-wise. - Unlike :class:`sklearn.pipeline.FeatureUnion`, the transformers' outputs + Unlike ``sklearn.pipeline.FeatureUnion``, the transformers' outputs are aligned on their index and returned as a DataFrame. Rows with missing values after alignment (e.g. warm-up rows of lag features) are dropped. @@ -36,7 +36,7 @@ def __init__(self, transformer_list): def _iter(self): for name, transformer in self.transformer_list: - if transformer is None or transformer == 'drop': + if transformer is None or transformer == "drop": continue yield name, transformer @@ -46,8 +46,7 @@ def fit(self, X, y=None, **fit_params): return self def fit_transform(self, X, y=None, **fit_params): - outputs = [transformer.fit_transform(X, y, **fit_params) - for _, transformer in self._iter()] + outputs = [transformer.fit_transform(X, y, **fit_params) for _, transformer in self._iter()] return self.merge_dataframes_by_column(outputs) def transform(self, X): @@ -66,9 +65,9 @@ def merge_dataframes_by_column(outputs): pandas.DataFrame: The merged features without missing rows. """ if not outputs: - raise ValueError('PandasFeatureUnion has no transformers.') - logger.debug('Merging %d feature blocks', len(outputs)) - return pd.concat(outputs, axis='columns').dropna() + raise ValueError("PandasFeatureUnion has no transformers.") + logger.debug("Merging %d feature blocks", len(outputs)) + return pd.concat(outputs, axis="columns").dropna() def contour_geojson(y, bbox, resolution, cmin, cmax): @@ -81,7 +80,7 @@ def contour_geojson(y, bbox, resolution, cmin, cmax): Args: y (pandas.Series): Values indexed by the positional index of the full point grid returned by - :func:`predspot.crime_mapping.create_gridpoints` (before + [`create_gridpoints`][predspot.crime_mapping.create_gridpoints] (before clipping), i.e. the ``places`` index. bbox (GeoDataFrame): Study area used to build the grid. resolution (float): Grid resolution in kilometers (same as the grid). @@ -94,14 +93,16 @@ def contour_geojson(y, bbox, resolution, cmin, cmax): try: import geojsoncontour except ImportError as exc: # pragma: no cover - optional dependency - raise ImportError('contour_geojson requires the optional dependency ' - '`geojsoncontour`: pip install predspot[contour]') from exc + raise ImportError( + "contour_geojson requires the optional dependency " + "`geojsoncontour`: pip install predspot[contour]" + ) from exc import matplotlib - matplotlib.use('Agg') + + matplotlib.use("Agg") import matplotlib.pyplot as plt - from predspot.crime_mapping import (KM_PER_DEG_LAT, KM_PER_DEG_LON, - _check_bbox, _wgs84_bounds) + from predspot.crime_mapping import KM_PER_DEG_LAT, KM_PER_DEG_LON, _check_bbox, _wgs84_bounds _check_bbox(bbox) b_w, b_s, b_e, b_n = _wgs84_bounds(bbox) @@ -113,8 +114,7 @@ def contour_geojson(y, bbox, resolution, cmin, cmax): Z = Z.reshape(lonv.shape) fig, axes = plt.subplots() - contourf = axes.contourf(lonv, latv, Z, levels=np.linspace(cmin, cmax, 25), - cmap='Spectral_r') + contourf = axes.contourf(lonv, latv, Z, levels=np.linspace(cmin, cmax, 25), cmap="Spectral_r") geojson = geojsoncontour.contourf_to_geojson(contourf=contourf, fill_opacity=0.5) plt.close(fig) return geojson diff --git a/tests/conftest.py b/tests/conftest.py index 0b5cafd..7079c3e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,13 +10,12 @@ BOUNDS = (-35.30, -5.90, -35.20, -5.80) -@pytest.fixture(scope='session') +@pytest.fixture(scope="session") def study_area(): - return gpd.GeoDataFrame({'name': ['test']}, - geometry=[box(*BOUNDS)], crs='EPSG:4326') + return gpd.GeoDataFrame({"name": ["test"]}, geometry=[box(*BOUNDS)], crs="EPSG:4326") -@pytest.fixture(scope='session') +@pytest.fixture(scope="session") def crimes(): """2 years of events: a uniform background plus one Gaussian hotspot.""" rng = np.random.default_rng(42) @@ -24,15 +23,18 @@ def crimes(): west, south, east, north = BOUNDS lon = np.concatenate([rng.uniform(west, east, n_bg), rng.normal(-35.23, 0.01, n_hot)]) lat = np.concatenate([rng.uniform(south, north, n_bg), rng.normal(-5.83, 0.01, n_hot)]) - start = pd.Timestamp('2019-01-01') + start = pd.Timestamp("2019-01-01") seconds = rng.integers(0, 730 * 24 * 3600, n_bg + n_hot) - return pd.DataFrame({ - 'tag': rng.choice(['burglary', 'assault'], n_bg + n_hot, p=[0.8, 0.2]), - 't': start + pd.to_timedelta(seconds, unit='s'), - 'lon': np.clip(lon, west, east), 'lat': np.clip(lat, south, north), - }) + return pd.DataFrame( + { + "tag": rng.choice(["burglary", "assault"], n_bg + n_hot, p=[0.8, 0.2]), + "t": start + pd.to_timedelta(seconds, unit="s"), + "lon": np.clip(lon, west, east), + "lat": np.clip(lat, south, north), + } + ) -@pytest.fixture(scope='session') +@pytest.fixture(scope="session") def dataset(crimes, study_area): return Dataset(crimes, study_area) diff --git a/tests/test_crime_mapping.py b/tests/test_crime_mapping.py index 257eda1..641de0b 100644 --- a/tests/test_crime_mapping.py +++ b/tests/test_crime_mapping.py @@ -1,3 +1,4 @@ +import geopandas as gpd import numpy as np import pandas as pd import pytest @@ -5,24 +6,26 @@ from predspot import crime_mapping as cm -@pytest.mark.parametrize('tfreq,expected', [('M', 'ME'), ('m', 'ME'), ('ME', 'ME'), - ('W', 'W'), ('D', 'D'), ('daily', 'D')]) +@pytest.mark.parametrize( + "tfreq,expected", + [("M", "ME"), ("m", "ME"), ("ME", "ME"), ("W", "W"), ("D", "D"), ("daily", "D")], +) def test_normalize_tfreq(tfreq, expected): assert cm.normalize_tfreq(tfreq) == expected def test_normalize_tfreq_invalid(): with pytest.raises(ValueError): - cm.normalize_tfreq('Y') + cm.normalize_tfreq("Y") def test_create_gridpoints(study_area): grid = cm.create_gridpoints(study_area, resolution=1) - assert grid.index.name == 'places' - assert {'lon', 'lat', 'geometry'} <= set(grid.columns) + assert grid.index.name == "places" + assert {"lon", "lat", "geometry"} <= set(grid.columns) # ~11 x 11 points for a ~10x10 km box at 1 km spacing assert 100 <= len(grid) <= 200 - assert grid.geometry.geom_type.eq('Point').all() + assert grid.geometry.geom_type.eq("Point").all() assert grid.crs == study_area.crs @@ -45,23 +48,23 @@ def test_create_gridpoints_errors(study_area): with pytest.raises(TypeError): cm.create_gridpoints(study_area.geometry, resolution=1) # a diamond: the corners of its bounding box fall outside the polygon - from shapely.geometry import Polygon import geopandas as gpd - sliver = gpd.GeoDataFrame(geometry=[Polygon([(-35.25, -5.90), (-35.20, -5.85), (-35.25, -5.80), (-35.30, -5.85)])], - crs='EPSG:4326') - with pytest.raises(ValueError, match='coarse'): + from shapely.geometry import Polygon + + diamond = Polygon([(-35.25, -5.90), (-35.20, -5.85), (-35.25, -5.80), (-35.30, -5.85)]) + sliver = gpd.GeoDataFrame(geometry=[diamond], crs="EPSG:4326") + with pytest.raises(ValueError, match="coarse"): cm.create_gridpoints(sliver, resolution=10000) def test_create_gridhexagonal(study_area): grid = cm.create_gridhexagonal(study_area, resolution=1) - assert grid.index.name == 'places' - assert grid.geometry.geom_type.eq('Polygon').all() - assert {'lon', 'lat'} <= set(grid.columns) + assert grid.index.name == "places" + assert grid.geometry.geom_type.eq("Polygon").all() + assert {"lon", "lat"} <= set(grid.columns) # every centroid must lie inside its own hexagon - inside = [geom.contains(pt) for geom, pt in - zip(grid.geometry, __import__('geopandas').points_from_xy(grid.lon, grid.lat))] - assert all(inside) + centroids = gpd.points_from_xy(grid.lon, grid.lat) + assert all(geom.contains(pt) for geom, pt in zip(grid.geometry, centroids, strict=True)) # equal-area hexagons: the study area (~100 km2) needs ~100-150 cells of 1 km2 assert 90 <= len(grid) <= 170 assert grid.geometry.union_all().covers(study_area.geometry.iloc[0]) @@ -69,91 +72,91 @@ def test_create_gridhexagonal(study_area): def test_create_gridsquares(study_area): grid = cm.create_gridsquares(study_area, resolution=1) - assert grid.index.name == 'places' - assert grid.geometry.geom_type.eq('Polygon').all() + assert grid.index.name == "places" + assert grid.geometry.geom_type.eq("Polygon").all() assert 100 <= len(grid) <= 150 assert grid.geometry.union_all().covers(study_area.geometry.iloc[0]) -@pytest.fixture(scope='module') +@pytest.fixture(scope="module") def points_grid(study_area): return cm.create_gridpoints(study_area, resolution=1) -@pytest.fixture(scope='module') +@pytest.fixture(scope="module") def hex_grid(study_area): return cm.create_gridhexagonal(study_area, resolution=1) def test_kde_transform(dataset, points_grid): - kde = cm.KDE(tfreq='M', grid=points_grid) + kde = cm.KDE(tfreq="M", grid=points_grid) st = kde.fit_transform(dataset.crimes) assert isinstance(st, pd.Series) - assert st.name == 'crime_density' - assert st.index.names == ['t', 'places'] - times = st.index.get_level_values('t').unique() + assert st.name == "crime_density" + assert st.index.names == ["t", "places"] + times = st.index.get_level_values("t").unique() assert len(times) == 24 # 2019-01 .. 2020-12 - assert (times == pd.date_range('2019-01-31', '2020-12-31', freq='ME')).all() + assert (times == pd.date_range("2019-01-31", "2020-12-31", freq="ME")).all() assert len(st) == 24 * len(points_grid) assert not st.isna().any() assert (st >= 0).all() assert kde.factor is not None and kde.factor > 0 # the hotspot around (-35.23, -5.83) must be denser than the far corner - month = st.xs(times[5], level='t') + month = st.xs(times[5], level="t") hot = ((points_grid.lon - (-35.23)).abs() < 0.005) & ((points_grid.lat - (-5.83)).abs() < 0.005) cold = (points_grid.lon < -35.29) & (points_grid.lat < -5.89) assert month[hot.values].mean() > month[cold.values].mean() def test_kde_bandwidth_options(dataset, points_grid): - fixed = cm.KDE(tfreq='M', grid=points_grid, bandwidth=0.3) + fixed = cm.KDE(tfreq="M", grid=points_grid, bandwidth=0.3) fixed.fit_transform(dataset.crimes) assert fixed.factor == 0.3 - scott = cm.KDE(tfreq='M', grid=points_grid, bandwidth='scott') + scott = cm.KDE(tfreq="M", grid=points_grid, bandwidth="scott") scott.fit_transform(dataset.crimes) assert scott.factor > 0 - auto = cm.KDE(tfreq='M', grid=points_grid, bandwidth='auto') - assert auto._bw_method == 'silverman' + auto = cm.KDE(tfreq="M", grid=points_grid, bandwidth="auto") + assert auto._bw_method == "silverman" with pytest.raises(ValueError): - cm.KDE(tfreq='M', grid=points_grid, bandwidth='gaussian') + cm.KDE(tfreq="M", grid=points_grid, bandwidth="gaussian") with pytest.raises(ValueError): - cm.KDE(tfreq='M', grid=points_grid, bandwidth=-1) + cm.KDE(tfreq="M", grid=points_grid, bandwidth=-1) def test_kde_start_end_time(dataset, points_grid): - kde = cm.KDE(tfreq='M', grid=points_grid, start_time='2018-06-01', end_time='2021-03-31') + kde = cm.KDE(tfreq="M", grid=points_grid, start_time="2018-06-01", end_time="2021-03-31") st = kde.fit_transform(dataset.crimes) - times = st.index.get_level_values('t').unique() - assert times.min() == pd.Timestamp('2018-06-30') - assert times.max() == pd.Timestamp('2021-03-31') - assert (st.xs(pd.Timestamp('2018-06-30'), level='t') == 0).all() + times = st.index.get_level_values("t").unique() + assert times.min() == pd.Timestamp("2018-06-30") + assert times.max() == pd.Timestamp("2021-03-31") + assert (st.xs(pd.Timestamp("2018-06-30"), level="t") == 0).all() def test_kde_weekly_and_daily(dataset, points_grid): - weekly = cm.KDE(tfreq='W', grid=points_grid).fit_transform(dataset.crimes) - assert 100 <= len(weekly.index.get_level_values('t').unique()) <= 106 - small = dataset.crimes[dataset.crimes['t'] < '2019-02-01'] - daily = cm.KDE(tfreq='D', grid=points_grid).fit_transform(small) - assert len(daily.index.get_level_values('t').unique()) == 31 + weekly = cm.KDE(tfreq="W", grid=points_grid).fit_transform(dataset.crimes) + assert 100 <= len(weekly.index.get_level_values("t").unique()) <= 106 + small = dataset.crimes[dataset.crimes["t"] < "2019-02-01"] + daily = cm.KDE(tfreq="D", grid=points_grid).fit_transform(small) + assert len(daily.index.get_level_values("t").unique()) == 31 def test_kde_few_points_gives_zeros(dataset, points_grid): two = dataset.crimes.iloc[:2] - st = cm.KDE(tfreq='M', grid=points_grid).fit_transform(two) + st = cm.KDE(tfreq="M", grid=points_grid).fit_transform(two) assert (st == 0).all() def test_kde_grid_validation(points_grid): - with pytest.raises(ValueError, match='lon'): - cm.KDE(tfreq='M', grid=points_grid.drop(columns=['lon'])) + with pytest.raises(ValueError, match="lon"): + cm.KDE(tfreq="M", grid=points_grid.drop(columns=["lon"])) def test_quadrat_count(dataset, hex_grid): - qc = cm.QuadratCount(tfreq='M', grid=hex_grid) + qc = cm.QuadratCount(tfreq="M", grid=hex_grid) st = qc.fit_transform(dataset.crimes) - assert st.name == 'crime_density' - assert st.index.names == ['t', 'places'] - assert len(st.index.get_level_values('t').unique()) == 24 + assert st.name == "crime_density" + assert st.index.names == ["t", "places"] + assert len(st.index.get_level_values("t").unique()) == 24 assert len(st) == 24 * len(hex_grid) assert (st >= 0).all() assert np.allclose(st, np.round(st)) @@ -163,10 +166,10 @@ def test_quadrat_count(dataset, hex_grid): def test_quadrat_count_squares(dataset, study_area): grid = cm.create_gridsquares(study_area, resolution=2) - st = cm.QuadratCount(tfreq='W', grid=grid).fit_transform(dataset.crimes) + st = cm.QuadratCount(tfreq="W", grid=grid).fit_transform(dataset.crimes) assert st.sum() == len(dataset.crimes) def test_quadrat_count_requires_polygons(points_grid): - with pytest.raises(ValueError, match='polygonal'): - cm.QuadratCount(tfreq='M', grid=points_grid) + with pytest.raises(ValueError, match="polygonal"): + cm.QuadratCount(tfreq="M", grid=points_grid) diff --git a/tests/test_dataset_preparation.py b/tests/test_dataset_preparation.py index 1e7505c..2deb72d 100644 --- a/tests/test_dataset_preparation.py +++ b/tests/test_dataset_preparation.py @@ -9,25 +9,25 @@ def test_dataset_builds_points_in_wgs84(crimes, study_area): ds = Dataset(crimes, study_area) assert isinstance(ds.crimes, gpd.GeoDataFrame) assert ds.crimes.crs.to_epsg() == 4326 - assert pd.api.types.is_datetime64_any_dtype(ds.crimes['t']) - assert ds.crimes.geometry.geom_type.eq('Point').all() - assert ds.shape == {'crimes': (len(crimes), 5), 'study_area': (1, 2)} - assert 'predspot.Dataset' in repr(ds) + assert pd.api.types.is_datetime64_any_dtype(ds.crimes["t"]) + assert ds.crimes.geometry.geom_type.eq("Point").all() + assert ds.shape == {"crimes": (len(crimes), 5), "study_area": (1, 2)} + assert "predspot.Dataset" in repr(ds) def test_dataset_does_not_mutate_input(crimes, study_area): before = crimes.copy() Dataset(crimes, study_area) pd.testing.assert_frame_equal(crimes, before) - assert 'geometry' not in crimes.columns + assert "geometry" not in crimes.columns def test_dataset_validation(crimes, study_area): with pytest.raises(TypeError): Dataset(crimes, study_area.geometry.iloc[0]) - with pytest.raises(ValueError, match='missing'): - Dataset(crimes.drop(columns=['lat']), study_area) - with pytest.raises(ValueError, match='CRS'): + with pytest.raises(ValueError, match="missing"): + Dataset(crimes.drop(columns=["lat"]), study_area) + with pytest.raises(ValueError, match="CRS"): Dataset(crimes, study_area.set_crs(None, allow_override=True)) @@ -41,6 +41,7 @@ def test_train_test_split(dataset): def test_plot(dataset): import matplotlib - matplotlib.use('Agg') + + matplotlib.use("Agg") ax = dataset.plot(crime_samples=50) assert ax is not None diff --git a/tests/test_feature_engineering.py b/tests/test_feature_engineering.py index a595e7e..b58f846 100644 --- a/tests/test_feature_engineering.py +++ b/tests/test_feature_engineering.py @@ -8,68 +8,81 @@ from predspot.utilities import PandasFeatureUnion -@pytest.fixture(scope='module') +@pytest.fixture(scope="module") def stseries(dataset, study_area): grid = cm.create_gridpoints(study_area, resolution=2) - return cm.KDE(tfreq='M', grid=grid).fit_transform(dataset.crimes) + return cm.KDE(tfreq="M", grid=grid).fit_transform(dataset.crimes) def _check_features(X, stseries, label, lags, first_time): - assert list(X.columns) == [f'{label}_{i}' for i in range(1, lags + 1)] - assert X.index.names == ['t', 'places'] - times = X.index.get_level_values('t').unique() - last_obs = stseries.index.get_level_values('t').max() + assert list(X.columns) == [f"{label}_{i}" for i in range(1, lags + 1)] + assert X.index.names == ["t", "places"] + times = X.index.get_level_values("t").unique() + last_obs = stseries.index.get_level_values("t").max() assert times.max() == last_obs + pd.offsets.MonthEnd(1) # next period row assert times.min() == first_time - n_places = stseries.index.get_level_values('places').nunique() + n_places = stseries.index.get_level_values("places").nunique() assert len(X) == len(times) * n_places assert not X.isna().any().any() def test_ar_features(stseries): - X = fe.AR(lags=3, tfreq='M').fit_transform(stseries) - _check_features(X, stseries, 'ar', 3, pd.Timestamp('2019-04-30')) + X = fe.AR(lags=3, tfreq="M").fit_transform(stseries) + _check_features(X, stseries, "ar", 3, pd.Timestamp("2019-04-30")) # ar_1 at t equals the series at t-1 - place = stseries.index.get_level_values('places')[0] - assert np.isclose(X.loc[(pd.Timestamp('2019-04-30'), place), 'ar_1'], - stseries.loc[(pd.Timestamp('2019-03-31'), place)]) + place = stseries.index.get_level_values("places")[0] + assert np.isclose( + X.loc[(pd.Timestamp("2019-04-30"), place), "ar_1"], + stseries.loc[(pd.Timestamp("2019-03-31"), place)], + ) def test_diff_features(stseries): - X = fe.Diff(lags=2, tfreq='M').fit_transform(stseries) - _check_features(X, stseries, 'diff', 2, pd.Timestamp('2019-04-30')) + X = fe.Diff(lags=2, tfreq="M").fit_transform(stseries) + _check_features(X, stseries, "diff", 2, pd.Timestamp("2019-04-30")) def test_seasonality_and_trend(stseries): - S = fe.Seasonality(lags=6, tfreq='M').fit_transform(stseries) - T = fe.Trend(lags=6, tfreq='M').fit_transform(stseries) - _check_features(S, stseries, 'seasonal', 6, pd.Timestamp('2019-07-31')) - _check_features(T, stseries, 'trend', 6, pd.Timestamp('2019-07-31')) + S = fe.Seasonality(lags=6, tfreq="M").fit_transform(stseries) + T = fe.Trend(lags=6, tfreq="M").fit_transform(stseries) + _check_features(S, stseries, "seasonal", 6, pd.Timestamp("2019-07-31")) + _check_features(T, stseries, "trend", 6, pd.Timestamp("2019-07-31")) assert not np.allclose(S.values, T.values) def test_tfreq_is_inferred_when_omitted(stseries): X = fe.AR(lags=2).fit_transform(stseries) - assert X.index.get_level_values('t').max() == pd.Timestamp('2021-01-31') + assert X.index.get_level_values("t").max() == pd.Timestamp("2021-01-31") def test_validation(stseries): with pytest.raises(ValueError): fe.AR(lags=1) - with pytest.raises(ValueError, match='lags'): - fe.AR(lags=30, tfreq='M').fit_transform(stseries) - with pytest.raises(ValueError, match='2 \\* lags'): - fe.Seasonality(lags=13, tfreq='M').fit_transform(stseries) + with pytest.raises(ValueError, match="lags"): + fe.AR(lags=30, tfreq="M").fit_transform(stseries) + with pytest.raises(ValueError, match="2 \\* lags"): + fe.Seasonality(lags=13, tfreq="M").fit_transform(stseries) def test_pandas_feature_union(stseries): - union = PandasFeatureUnion([('ar', fe.AR(lags=2, tfreq='M')), - ('seasonal', fe.Seasonality(lags=4, tfreq='M')), - ('skip', None)]) + union = PandasFeatureUnion( + [ + ("ar", fe.AR(lags=2, tfreq="M")), + ("seasonal", fe.Seasonality(lags=4, tfreq="M")), + ("skip", None), + ] + ) X = union.fit_transform(stseries) - assert list(X.columns) == ['ar_1', 'ar_2', 'seasonal_1', 'seasonal_2', 'seasonal_3', 'seasonal_4'] + assert list(X.columns) == [ + "ar_1", + "ar_2", + "seasonal_1", + "seasonal_2", + "seasonal_3", + "seasonal_4", + ] # rows are aligned on the intersection of the indexes (the STL warm-up wins) - assert X.index.get_level_values('t').min() == pd.Timestamp('2019-05-31') + assert X.index.get_level_values("t").min() == pd.Timestamp("2019-05-31") assert not X.isna().any().any() pd.testing.assert_frame_equal(union.transform(stseries), X) with pytest.raises(ValueError): @@ -77,7 +90,7 @@ def test_pandas_feature_union(stseries): def test_feature_scaling(stseries): - X = fe.AR(lags=2, tfreq='M').fit_transform(stseries) + X = fe.AR(lags=2, tfreq="M").fit_transform(stseries) scaled = fe.FeatureScaling(StandardScaler()).fit_transform(X) assert isinstance(scaled, pd.DataFrame) assert scaled.index.equals(X.index) and list(scaled.columns) == list(X.columns) diff --git a/tests/test_load_study_area.py b/tests/test_load_study_area.py new file mode 100644 index 0000000..92b7640 --- /dev/null +++ b/tests/test_load_study_area.py @@ -0,0 +1,86 @@ +import os +import sys +import types + +import geopandas as gpd +import pytest +from shapely.geometry import Point, box + +from predspot.crime_mapping import get_city_shape, load_study_area + + +def _fake_osmnx(monkeypatch, geometry): + """Install a fake `osmnx` module whose geocode_to_gdf returns `geometry`.""" + calls = {} + + def geocode_to_gdf(query, which_result=None): + calls["query"], calls["which_result"] = query, which_result + return gpd.GeoDataFrame( + { + "osm_type": ["relation"], + "osm_id": [1], + "name": ["Natal"], + "display_name": ["Natal, Rio Grande do Norte, Brasil"], + "place_rank": [16], + }, + geometry=[geometry], + crs="EPSG:4326", + ) + + fake = types.ModuleType("osmnx") + fake.geocode_to_gdf = geocode_to_gdf + monkeypatch.setitem(sys.modules, "osmnx", fake) + return calls + + +def test_load_study_area_returns_polygon(monkeypatch): + calls = _fake_osmnx(monkeypatch, box(-35.3, -5.9, -35.2, -5.8)) + area = load_study_area("Natal, Brazil") + assert calls == {"query": "Natal, Brazil", "which_result": None} + assert list(area.columns) == ["name", "display_name", "osm_type", "osm_id", "geometry"] + assert area.crs.to_epsg() == 4326 + assert area.geometry.iloc[0].geom_type == "Polygon" + + +def test_load_study_area_reprojects(monkeypatch): + _fake_osmnx(monkeypatch, box(-35.3, -5.9, -35.2, -5.8)) + area = load_study_area("Natal, Brazil", crs="EPSG:31985") + assert area.crs.to_epsg() == 31985 + + +def test_load_study_area_rejects_points(monkeypatch): + _fake_osmnx(monkeypatch, Point(-35.2, -5.8)) + with pytest.raises(ValueError, match="non-polygon"): + load_study_area("Somewhere") + + +def test_load_study_area_without_osmnx(monkeypatch): + monkeypatch.setitem(sys.modules, "osmnx", None) + with pytest.raises(ImportError, match="predspot\\[osm\\]"): + load_study_area("Natal, Brazil") + + +@pytest.mark.skipif( + not os.environ.get("PREDSPOT_NETWORK_TESTS"), + reason="set PREDSPOT_NETWORK_TESTS=1 to query OpenStreetMap", +) +def test_load_study_area_network(): + pytest.importorskip("osmnx") + area = load_study_area("Natal, Rio Grande do Norte, Brazil") + assert len(area) == 1 + assert area.geometry.iloc[0].geom_type in ("Polygon", "MultiPolygon") + assert area.geometry.iloc[0].contains(Point(-35.2094, -5.7945)) # city centre + + +def test_get_city_shape_returns_raw_geocode(monkeypatch): + calls = _fake_osmnx(monkeypatch, box(-35.3, -5.9, -35.2, -5.8)) + city = get_city_shape("Natal, RN, Brazil") + assert calls["query"] == "Natal, RN, Brazil" + assert city.geometry.iloc[0].geom_type == "Polygon" + assert "place_rank" in city.columns # raw Nominatim columns are kept + + +def test_get_city_shape_without_osmnx(monkeypatch): + monkeypatch.setitem(sys.modules, "osmnx", None) + with pytest.raises(ImportError, match="predspot\\[osm\\]"): + get_city_shape("Natal, RN, Brazil") diff --git a/tests/test_ml_modelling.py b/tests/test_ml_modelling.py index 7ea914e..d6cac36 100644 --- a/tests/test_ml_modelling.py +++ b/tests/test_ml_modelling.py @@ -15,22 +15,30 @@ def make_pipeline(grid, mapping_cls=cm.KDE, estimator=None): return ml.PredictionPipeline( - mapping=mapping_cls(tfreq='M', grid=grid), - fextraction=PandasFeatureUnion([ - ('seasonal', fe.Seasonality(lags=4, tfreq='M')), - ('trend', fe.Trend(lags=4, tfreq='M')), - ('diff', fe.Diff(lags=4, tfreq='M')), - ]), - estimator=estimator or Pipeline([ - ('f_scaling', fe.FeatureScaling(QuantileTransformer(n_quantiles=10))), - ('f_selection', ml.FeatureSelection(RFE(RandomForestRegressor(n_estimators=5, random_state=0)))), - ('model', ml.Model(RandomForestRegressor(n_estimators=10, random_state=0))), - ]), + mapping=mapping_cls(tfreq="M", grid=grid), + fextraction=PandasFeatureUnion( + [ + ("seasonal", fe.Seasonality(lags=4, tfreq="M")), + ("trend", fe.Trend(lags=4, tfreq="M")), + ("diff", fe.Diff(lags=4, tfreq="M")), + ] + ), + estimator=estimator + or Pipeline( + [ + ("f_scaling", fe.FeatureScaling(QuantileTransformer(n_quantiles=10))), + ( + "f_selection", + ml.FeatureSelection(RFE(RandomForestRegressor(n_estimators=5, random_state=0))), + ), + ("model", ml.Model(RandomForestRegressor(n_estimators=10, random_state=0))), + ] + ), random_state=0, ) -@pytest.fixture(scope='module') +@pytest.fixture(scope="module") def fitted(dataset, study_area): grid = cm.create_gridpoints(study_area, resolution=2) return make_pipeline(grid).fit(dataset) @@ -38,37 +46,41 @@ def fitted(dataset, study_area): def test_fit_predict(fitted): n_places = len(fitted.grid) - assert fitted.next_time == pd.Timestamp('2021-01-31') + assert fitted.next_time == pd.Timestamp("2021-01-31") pred = fitted.predict() - assert list(pred.columns) == ['crime_density'] + assert list(pred.columns) == ["crime_density"] assert len(pred) == n_places - assert pred.index.get_level_values('t').unique().tolist() == [pd.Timestamp('2021-01-31')] - assert (pred['crime_density'] >= 0).all() + assert pred.index.get_level_values("t").unique().tolist() == [pd.Timestamp("2021-01-31")] + assert (pred["crime_density"] >= 0).all() # the forecast is appended to the series and the horizon moves forward - assert fitted.stseries.index.get_level_values('t').max() == pd.Timestamp('2021-01-31') - assert fitted.next_time == pd.Timestamp('2021-02-28') + assert fitted.stseries.index.get_level_values("t").max() == pd.Timestamp("2021-01-31") + assert fitted.next_time == pd.Timestamp("2021-02-28") pred2 = fitted.predict() - assert pred2.index.get_level_values('t').unique().tolist() == [pd.Timestamp('2021-02-28')] + assert pred2.index.get_level_values("t").unique().tolist() == [pd.Timestamp("2021-02-28")] def test_feature_importances(fitted): fi = fitted.feature_importances - assert list(fi.columns) == ['importance'] - assert np.isclose(fi['importance'].sum(), 1) + assert list(fi.columns) == ["importance"] + assert np.isclose(fi["importance"].sum(), 1) assert set(fi.index) <= set(fitted.features.columns) def test_evaluate(dataset, study_area): grid = cm.create_gridpoints(study_area, resolution=2) pipe = make_pipeline(grid).fit(dataset) - scores = pipe.evaluate('r2', cv=3) + scores = pipe.evaluate("r2", cv=3) assert len(scores) == 3 - mse = pipe.evaluate('mse', cv=3) + mse = pipe.evaluate("mse", cv=3) assert all(s >= 0 for s in mse) + both = pipe.evaluate(["r2", "mse"], cv=3) + assert list(both.columns) == ["r2", "mse"] and len(both) == 3 with pytest.raises(ValueError): - pipe.evaluate('mae') + pipe.evaluate("mae") with pytest.raises(ValueError): - pipe.evaluate('r2', cv=100) + pipe.evaluate([]) + with pytest.raises(ValueError): + pipe.evaluate("r2", cv=100) # evaluate refits on the full data: predictions still work afterwards assert len(pipe.predict()) == len(grid) @@ -79,7 +91,7 @@ def test_plain_sklearn_estimator(dataset, study_area): pred = pipe.predict() assert len(pred) == len(grid) with pytest.raises(AttributeError): - pipe.feature_importances + _ = pipe.feature_importances def test_quadrat_count_pipeline(dataset, study_area): diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index 83da5be..be7a53a 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -5,24 +5,25 @@ def test_generate_testdata(): - crimes, area = pipeline.generate_testdata(500, '2019-01-01', '2019-12-31', seed=1) - assert list(crimes.columns) == ['tag', 't', 'lon', 'lat'] + crimes, area = pipeline.generate_testdata(500, "2019-01-01", "2019-12-31", seed=1) + assert list(crimes.columns) == ["tag", "t", "lon", "lat"] assert len(crimes) == 500 assert area.crs.to_epsg() == 4326 - assert crimes['t'].between('2019-01-01', '2019-12-31').all() - again, _ = pipeline.generate_testdata(500, '2019-01-01', '2019-12-31', seed=1) + assert crimes["t"].between("2019-01-01", "2019-12-31").all() + again, _ = pipeline.generate_testdata(500, "2019-01-01", "2019-12-31", seed=1) pd.testing.assert_frame_equal(crimes, again) def test_run_prediction_pipeline(crimes, study_area): pred, pipe = pipeline.run_prediction_pipeline( - crimes, study_area, crime_tags=['burglary'], grid_resolution=2, random_state=0) + crimes, study_area, crime_tags=["burglary"], grid_resolution=2, random_state=0 + ) assert len(pred) == len(pipe.grid) - assert pred.index.get_level_values('t').unique().tolist() == [pd.Timestamp('2021-01-31')] - scores = pipeline.evaluate_pipeline(pipe, 'r2', cv=2) + assert pred.index.get_level_values("t").unique().tolist() == [pd.Timestamp("2021-01-31")] + scores = pipeline.evaluate_pipeline(pipe, "r2", cv=2) assert len(scores) == 2 def test_run_prediction_pipeline_validation(crimes, study_area): with pytest.raises(ValueError): - pipeline.run_prediction_pipeline(crimes.drop(columns=['tag']), study_area) + pipeline.run_prediction_pipeline(crimes.drop(columns=["tag"]), study_area) diff --git a/tests/test_synthetic.py b/tests/test_synthetic.py new file mode 100644 index 0000000..592f0a8 --- /dev/null +++ b/tests/test_synthetic.py @@ -0,0 +1,124 @@ +import geopandas as gpd +import numpy as np +import pandas as pd +import pytest +import shapely +from shapely.geometry import Polygon + +from predspot import Dataset, synthetic +from predspot.synthetic import generate_crimes + + +@pytest.fixture(scope="module") +def irregular_area(): + poly = Polygon( + [(-35.30, -5.90), (-35.18, -5.92), (-35.16, -5.85), (-35.20, -5.82), + (-35.19, -5.78), (-35.28, -5.77), (-35.32, -5.84)] + ) # fmt: skip + return gpd.GeoDataFrame(geometry=[poly], crs="EPSG:4326") + + +def test_generate_crimes_basic(irregular_area): + crimes, hotspots = generate_crimes( + irregular_area, n_events=3000, n_hotspots=3, seed=0, return_hotspots=True + ) + assert list(crimes.columns) == ["tag", "t", "lon", "lat"] + assert len(crimes) == 3000 + assert crimes["t"].is_monotonic_increasing + assert crimes["t"].between("2019-01-01", "2020-12-31").all() + polygon = irregular_area.geometry.iloc[0] + assert shapely.contains_xy(polygon, crimes["lon"].values, crimes["lat"].values).all() + assert len(hotspots) == 3 + assert hotspots.geometry.within(polygon).all() + assert np.isclose(hotspots["share"].sum(), 0.7) + # it plugs directly into Dataset + assert Dataset(crimes, irregular_area).shape["crimes"][0] == 3000 + + +def test_generate_crimes_is_reproducible(study_area): + a = generate_crimes(study_area, n_events=500, seed=123) + b = generate_crimes(study_area, n_events=500, seed=123) + pd.testing.assert_frame_equal(a, b) + c = generate_crimes(study_area, n_events=500, seed=124) + assert not a.equals(c) + + +def test_hotspots_concentrate_events(study_area): + crimes, hotspots = generate_crimes( + study_area, n_events=4000, n_hotspots=1, hotspot_share=0.8, hotspot_sd_km=0.3, + seed=1, return_hotspots=True, + ) # fmt: skip + center = hotspots.geometry.iloc[0] + d_lon = (crimes["lon"] - center.x) * 111.32 * np.cos(np.radians(center.y)) + d_lat = (crimes["lat"] - center.y) * 110.57 + within_1km = np.hypot(d_lon, d_lat) < 1.0 + # ~80% hotspot events within ~3 sd, plus a little background in a 100 km2 box + assert 0.7 < within_1km.mean() < 0.9 + + +def test_uniform_when_no_hotspots(study_area): + crimes = generate_crimes(study_area, n_events=4000, n_hotspots=0, seed=2) + west, south, east, north = study_area.total_bounds + # split the box into 4 quadrants; each should hold ~25% of the events + q = ((crimes["lon"] > (west + east) / 2).astype(int) * 2 + + (crimes["lat"] > (south + north) / 2).astype(int)) # fmt: skip + shares = q.value_counts(normalize=True) + assert len(shares) == 4 and (shares.between(0.2, 0.3)).all() + + +def test_temporal_patterns(study_area): + crimes = generate_crimes( + study_area, n_events=20000, trend=1.0, annual_amplitude=0.5, annual_peak_month=7, + weekly_profile=(1, 1, 1, 1, 1, 1, 6), hourly_profile=None, seed=3, + ) # fmt: skip + t = crimes["t"] + # weekly: Sunday gets 6/12 = 50% of the events + assert 0.45 < (t.dt.dayofweek == 6).mean() < 0.55 + # no hourly profile: hours roughly uniform + hours = t.dt.hour.value_counts(normalize=True) + assert hours.max() < 0.07 + # trend: the last year has more events than the first year + per_year = t.dt.year.value_counts() + assert per_year[2020] > 1.3 * per_year[2019] + # annual cycle peaks in July + assert t.dt.month.value_counts().idxmax() in (6, 7, 8) + + +def test_tags_weights(study_area): + crimes = generate_crimes(study_area, n_events=5000, tags={"theft": 3, "fraud": 1}, seed=4) + shares = crimes["tag"].value_counts(normalize=True) + assert set(shares.index) == {"theft", "fraud"} + assert 0.7 < shares["theft"] < 0.8 + listed = generate_crimes(study_area, n_events=200, tags=["a", "b", "c"], seed=4) + assert set(listed["tag"]) == {"a", "b", "c"} + + +def test_projected_study_area(study_area): + projected = study_area.to_crs(study_area.estimate_utm_crs()) + crimes = generate_crimes(projected, n_events=300, seed=5) + west, south, east, north = study_area.total_bounds + assert crimes["lon"].between(west, east).all() + assert crimes["lat"].between(south, north).all() + + +def test_validation(study_area): + with pytest.raises(ValueError): + generate_crimes(study_area, n_events=0) + with pytest.raises(ValueError): + generate_crimes(study_area, n_events=10, hotspot_share=1.5) + with pytest.raises(ValueError): + generate_crimes(study_area, n_events=10, tags={}) + with pytest.raises(ValueError, match="7 values"): + generate_crimes(study_area, n_events=10, weekly_profile=(1, 2)) + with pytest.raises(ValueError, match="24 values"): + generate_crimes(study_area, n_events=10, hourly_profile=(1, 2)) + with pytest.raises(ValueError, match="end must be after"): + generate_crimes(study_area, n_events=10, start="2020-01-01", end="2019-01-01") + + +def test_temporal_intensity_shape(): + start, end = pd.Timestamp("2019-01-01"), pd.Timestamp("2019-12-31") + stamps = pd.date_range(start, end, freq="D") + lam = synthetic.temporal_intensity(stamps, start, end, trend=1.0) + assert lam.shape == (len(stamps),) + assert np.isclose(lam[0], 1.0) and np.isclose(lam[-1], 2.0) diff --git a/tests/test_utilities.py b/tests/test_utilities.py index 88457e1..a4ba5fc 100644 --- a/tests/test_utilities.py +++ b/tests/test_utilities.py @@ -7,9 +7,9 @@ def test_contour_geojson(dataset, study_area): - pytest.importorskip('geojsoncontour') + pytest.importorskip("geojsoncontour") grid = cm.create_gridpoints(study_area, resolution=1) - st = cm.KDE(tfreq='M', grid=grid).fit_transform(dataset.crimes) - month = st.xs(st.index.get_level_values('t')[0], level='t') + st = cm.KDE(tfreq="M", grid=grid).fit_transform(dataset.crimes) + month = st.xs(st.index.get_level_values("t")[0], level="t") geojson = contour_geojson(month, study_area, 1, cmin=0, cmax=month.max()) - assert json.loads(geojson)['type'] == 'FeatureCollection' + assert json.loads(geojson)["type"] == "FeatureCollection"