Metadata-Version: 2.4
Name: geomask
Version: 0.2.1
Summary: Geographic masking of confidential point data (donut, adaptive donut, AAE, swapping, aggregation) with spatial k-anonymity and utility metrics.
Author-email: Yusuf Eminoğlu <yusufeminoglu@gmail.com>
License-Expression: MIT
Project-URL: Homepage, https://github.com/YusufEminoglu/geomask_sdk
Project-URL: Repository, https://github.com/YusufEminoglu/geomask_sdk
Project-URL: Issues, https://github.com/YusufEminoglu/geomask_sdk/issues
Project-URL: Changelog, https://github.com/YusufEminoglu/geomask_sdk/blob/main/CHANGELOG.md
Keywords: geographic-masking,geomasking,privacy,confidentiality,k-anonymity,spatial-k-anonymity,donut-masking,adaptive-areal-elimination,location-swapping,small-cell-suppression,public-health,epidemiology,gis,point-pattern,ripley,statistical-disclosure-control
Classifier: Development Status :: 4 - Beta
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Operating System :: OS Independent
Classifier: Topic :: Scientific/Engineering :: GIS
Classifier: Topic :: Scientific/Engineering :: Medical Science Apps.
Classifier: Topic :: Security
Classifier: Topic :: Sociology
Classifier: Intended Audience :: Science/Research
Classifier: Intended Audience :: Healthcare Industry
Classifier: Intended Audience :: Developers
Classifier: Typing :: Typed
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: numpy>=1.22.0
Provides-Extra: fast
Requires-Dist: scipy>=1.8.0; extra == "fast"
Provides-Extra: dev
Requires-Dist: pytest>=7.0.0; extra == "dev"
Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
Requires-Dist: ruff>=0.1.0; extra == "dev"
Requires-Dist: mypy>=1.0.0; extra == "dev"
Dynamic: license-file

# geomask

[![CI](https://github.com/YusufEminoglu/geomask_sdk/actions/workflows/ci.yml/badge.svg)](https://github.com/YusufEminoglu/geomask_sdk/actions/workflows/ci.yml)
[![PyPI](https://img.shields.io/pypi/v/geomask.svg)](https://pypi.org/project/geomask/)
[![Python](https://img.shields.io/pypi/pyversions/geomask.svg)](https://pypi.org/project/geomask/)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)

**Geographic masking of confidential point data, with the privacy and utility
metrics you need to document the trade-off.**

Public-health, social-science and urban researchers often hold point data that
identify people: home addresses of patients, locations of incidents, survey
respondents' dwellings. Publishing those points, or even a detailed map of
them, can let anyone with a street map work out who is who. `geomask`
implements the established *geomasking* methods from the literature and,
just as importantly, the metrics that tell you how much privacy a mask buys
and how much analytical value it costs.

* **Masks**: random perturbation, Gaussian displacement, donut masking,
  population-adaptive donut masking with a verified spatial k-anonymity
  guarantee, grid and hexagon snapping, grid / hexagon / Voronoi / label
  aggregation with small-cell suppression, location swapping and adaptive
  areal elimination (AAE).
* **Privacy metrics**: spatial k-anonymity per point, expected
  re-identification risk, nearest-neighbour linkage rate, displacement
  statistics.
* **Utility metrics**: Ripley's K / L difference, kernel-density correlation,
  Moran's I and hot-spot overlap of gridded counts, mean displacement.
* **`report()`**: a privacy-vs-utility table across methods.
* Reproducible (every random mask takes a `seed`), pure NumPy (SciPy optional
  for KD-tree speed), works in metres with built-in lon/lat helpers (UTM and
  local equirectangular), typed, and a CLI.

> **Masking is not anonymisation by itself.** Read the
> [ethics and limitations](#ethics-and-limitations) section before you share
> anything.

## Installation

```bash
pip install geomask            # NumPy only
pip install "geomask[fast]"    # + SciPy KD-trees for large data sets
```

Python 3.9 or later.

## Quick start

All examples use **synthetic** data generated by `geomask.synthetic_city`; no
real person is represented anywhere in this project.

```python
import geomask as gm

town = gm.synthetic_city(n_population=5000, n_cases=300, seed=1)
cases, homes = town.cases, town.population   # (n, 2) arrays in metres

# Donut mask: every point moves between 50 m and 300 m.
masked = gm.donut_mask(cases, min_radius=50, max_radius=300, seed=1)

# Population-adaptive donut: radii grow in sparse areas and every masked point
# is re-drawn until at least 10 households are closer to it than the true home.
safe = gm.donut_mask(cases, 50, 300, seed=1, population=homes, k=10)

k = gm.spatial_k_anonymity(cases, safe, homes)
print(k.min())                          # >= 10, audited independently

gm.report(gm.evaluate(cases, safe, population=homes, k=10))
```

### Comparing methods

`examples/compare_methods.py` masks the same synthetic cases with every
method and prints the trade-off table:

```python
results = gm.compare(cases, {
    "perturb r=250": gm.random_perturbation(cases, 250, seed=1),
    "gaussian s=100": gm.gaussian_displacement(cases, 100, seed=1),
    "donut 50-300": gm.donut_mask(cases, 50, 300, seed=1),
    "donut k>=10": gm.donut_mask(cases, 50, 300, seed=1, population=homes, k=10),
    "swap r=300": gm.location_swap(cases, homes, 300, seed=1),
    "AAE k=20": gm.adaptive_areal_elimination(cases, homes, k=20, cell_size=100, seed=1),
    "grid 250 k=5": gm.aggregate_to_grid(cases, 250, k=5).masked_points,
    "hex 150 k=5": gm.aggregate_to_grid(cases, 150, k=5, shape="hex").masked_points,
}, population=homes, k=10)
gm.report(results)
```

```text
method          k min  k med   <k %   risk  NN re-id  disp m  min m  dL max m  KDE r  Moran I  hot J  suppr
--------------  -----  -----  -----  -----  --------  ------  -----  --------  -----  -------  -----  -----
perturb r=250       0     42  20.0%  0.081     19.7%     165     19      26.9  0.999    0.700   0.36      0
gaussian s=100      0     19  35.0%  0.174     34.7%     118      4      23.1  0.998    0.702   0.47      0
donut 50-300        0     62  13.7%  0.049     15.7%     203     55      37.2  0.998    0.738   0.67      0
donut k>=10        10     66   0.0%  0.023      8.7%     224     66      31.9  0.997    0.755   0.62      0
swap r=300          1     88  12.0%  0.035     13.3%     207     26      45.3  0.989    0.663   0.53      0
AAE k=20            0     24  27.0%  0.122     32.3%     238      6      45.2  0.993    0.678   0.55      0
grid 250 k=5        0     18  28.5%  0.106      6.3%      93     14     727.8  0.932    0.507   0.25    170
hex 150 k=5         0     18  30.1%  0.120      6.7%      93     21     668.0  0.945    0.538   0.33    167
n = 300; k threshold = 10; Moran I of original counts = 0.708
```

How to read it: only the population-adaptive donut guarantees that *every*
point has at least k = 10 households closer to its released location than the
true one (`k min`), while keeping the density surface almost unchanged
(`KDE r` 0.997). Fixed-radius masks leave some rural points weakly protected
(`<k %`). Aggregation with suppression drops more than half the points in
this small sample (`suppr`) and distorts second-order structure (`dL max`).
AAE's guarantee is *areal* (each release area holds at least k residents),
which the point-based `k min` column does not measure - see below.

## Methods

| Method | Function | What it does | Privacy | Utility | Reference |
|---|---|---|---|---|---|
| Random perturbation | `random_perturbation` | Uniform random point within radius *r* (uniform over the disk, or uniform distance) | Weak: some points barely move | Good at scales above *r* | Armstrong et al. 1999 |
| Gaussian displacement | `gaussian_displacement` | Adds N(0, sigma^2) to x and y; distance is Rayleigh | Weak: unbounded small moves | Good, smooth | Armstrong et al. 1999; Zandbergen 2014 |
| Donut masking | `donut_mask` | Random point in an annulus [r_min, r_max] | Guaranteed minimum displacement | Bounded error r_max | Hampton et al. 2010 |
| Population-adaptive donut | `donut_mask(population=, k=)` | Radii from the k-th / k_max-th nearest population unit; candidates re-drawn until spatial k-anonymity >= k | **Verified k per point** | Small moves in dense areas, larger in sparse | Allshouse et al. 2010; Hampton et al. 2010 |
| Grid / hexagon snapping | `snap_to_grid`, `snap_to_hex` | Moves points to cell centres | Points in a cell become identical | Resolution = cell size | Armstrong et al. 1999 |
| Aggregation + suppression | `aggregate_to_grid`, `aggregate_to_voronoi`, `aggregate_by_label` | Counts per cell / Voronoi cell / area; counts 0 < c < k suppressed | Small-cell rule | Loses point detail; suppression loses rural cases | Armstrong et al. 1999 |
| Location swapping | `location_swap` | Replaces each point with a random real address within *r* (never itself) | Plausible locations; resists "point in a lake" checks | Similar to donut | Zhang et al. 2017 |
| Adaptive areal elimination | `adaptive_areal_elimination` | Merges grid cells until each holds >= k people, then relocates points randomly inside their merged area | **Areal k-anonymity** | Adaptive: small areas in dense places | Kounadi & Leitner 2016 |

Notes on parameters:

* Coordinates are **projected, in metres**. Use `gm.make_projection(lonlat)`
  (UTM zone of the data by default, or `method="local"`) and its
  `forward` / `inverse` methods to convert.
* `population` is any set of locations that people could plausibly live at:
  address points, building centroids or the centres of a population raster.
  Repeat a centre once per resident to weight it (AAE also accepts
  `population_weights`). The true location is usually in this set; that is
  expected and handled.
* `donut_mask(..., distribution="distance")` reproduces the original donut
  formulation (uniform distance); the default `"area"` is uniform over the
  annulus, which puts fewer points close to the inner ring.
* Aggregation results keep the *true* counts in `counts`; publish
  `released_counts` or `table()`, which show suppressed cells as `<k`.

## Metrics

| Metric | Function | Meaning |
|---|---|---|
| Spatial k-anonymity | `spatial_k_anonymity(orig, masked, population)` | Number of population units strictly closer to the masked point than the true location (the true location itself is never counted, so the anonymity set has k + 1 members). |
| Expected re-identification risk | `expected_reidentification_risk(k)` | mean(1 / (k + 1)): probability that an adversary picking uniformly among the candidates guesses right. |
| Nearest-neighbour linkage rate | `nearest_neighbour_reidentification_rate` | Share of masked points whose nearest *true* location is their own; models an adversary holding the list of true locations. |
| Displacement statistics | `displacement_stats` | Mean, median, SD, min, 5th / 95th percentile and max of distances moved. |
| Ripley's K / L difference | `ripley_k`, `ripley_l`, `ripley_l_difference` | L(r) = sqrt(K(r) / pi) for original and masked points over a shared window; `max_abs_difference` in metres. No edge correction (biases both patterns equally). |
| Kernel-density correlation | `kde_correlation`, `kernel_density` | Pearson correlation of Gaussian KDE surfaces (Scott bandwidth by default). |
| Cluster preservation | `cluster_preservation`, `morans_i` | Moran's I (rook contiguity) of gridded counts before and after masking, and Jaccard overlap of hot-spot cells. |
| Mean displacement | `mean_displacement` | Average distance moved, metres. |

`gm.evaluate()` computes all of them; `gm.compare()` evaluates several masks
with identical settings; `gm.report()` prints the table. Suppressed points
(`NaN` rows) are excluded from the masked pattern and counted in `suppr`.

## Command line

```bash
# Donut mask a lon/lat CSV (coordinates are auto-detected and projected to UTM)
geomask donut cases.csv --x lon --y lat --min 50 --max 300 --seed 1 -o masked.csv

# Population-adaptive donut with k >= 10
geomask donut cases.csv --x lon --y lat --min 50 --max 300 --k 10 \
    --population addresses.csv --seed 1 -o masked.csv
# (the k check keeps a safety margin that covers the --decimals rounding, and
#  the k-anonymity of the coordinates actually written is reported on stderr)

# Other methods
geomask perturb  cases.csv --radius 250 -o out.csv
geomask gaussian cases.csv --sigma 100 -o out.csv
geomask grid     cases.csv --cell 250 --k 5 -o out.csv
geomask hex      cases.csv --size 150 --k 5 -o out.csv
geomask voronoi  cases.csv --seeds clinics.csv --k 5 -o out.csv
geomask swap     cases.csv --radius 300 --population addresses.csv -o out.csv
geomask aae      cases.csv --k 20 --cell 100 --population addresses.csv -o out.csv

# Privacy / utility report (rows must be in the same order)
geomask evaluate cases.csv masked.csv --x lon --y lat --population addresses.csv --k 10
geomask evaluate cases.csv masked.csv --json
```

All other columns are copied unchanged; use `--drop name address ...` to
remove direct identifiers. Suppressed points are written with empty
coordinates. `--coords lonlat|projected` overrides auto-detection and
`--projection local` uses an equirectangular projection instead of UTM.

## Reproducibility

Every random function accepts `seed` (an `int`, `None` or a
`numpy.random.Generator`). The same seed, input and version give identical
output. **Do not publish the seed** together with the masked data: with the
seed and the method parameters, an adversary who knows some true locations
can test candidate locations. Store the seed with your confidential project
records.

## Ethics and limitations

* **Masking is not anonymisation by itself.** A masked point set can still be
  re-identified by linkage with other data (dates, ages, diagnoses, rare
  attributes), by repeated releases of differently masked versions of the
  same data, or by an adversary who knows the method and parameters. Treat
  masked data as *reduced-risk* personal data, not as anonymous data.
* **Consult your IRB / ethics committee and data-protection officer (DPO)**
  before sharing. They decide which risk is acceptable for your data, your
  participants and your recipients. This package helps you document the
  risk; it does not approve anything.
* **GDPR note (EU/EEA and similar laws).** Location data is listed as an
  identifier in Art. 4(1) GDPR, and health data are a special category
  (Art. 9). Under Recital 26, data remain personal data as long as a person
  is identifiable by means reasonably likely to be used; geomasked data for
  which you keep the original coordinates or a re-identification key are
  generally *pseudonymised*, not anonymised, and the GDPR still applies. A
  data-protection impact assessment (Art. 35) may be required. This is not
  legal advice.
* **Guarantees are conditional.** Spatial k-anonymity is only as good as the
  `population` layer: missing or outdated address data overstate k. AAE's
  guarantee is areal (at least k residents per release area), not a
  guarantee about distances.
* **Small-cell suppression** does not stop differencing attacks when several
  overlapping tables (other cell sizes, other years, sub-groups) are
  published, and it removes cases disproportionately from sparse, often
  rural or marginalised, areas - a bias to report in your methods.
* **Edge effects**: points near a study-area boundary, a coastline or an
  uninhabited area can be displaced to implausible places (except with
  swapping or AAE `placement="population"`), which an adversary can exploit
  to narrow the search.
* **Utility metrics are global summaries.** A high KDE correlation does not
  guarantee that a specific local cluster survived; re-run your actual
  analysis on the masked data before publishing results based on it.
* Use only synthetic or properly authorised data when experimenting. Never
  commit real personal data to a code repository.

## API overview

```text
Masks         random_perturbation, gaussian_displacement, donut_mask (DonutResult),
              location_swap, snap_to_grid, snap_to_hex,
              aggregate_to_grid, aggregate_to_voronoi, aggregate_by_label (AggregationResult),
              adaptive_areal_elimination (AAEResult)
Privacy       spatial_k_anonymity, expected_reidentification_risk,
              nearest_neighbour_reidentification_rate, displacement, displacement_stats
Utility       ripley_k, ripley_l, ripley_l_difference, kernel_density, kde_correlation,
              morans_i, cluster_preservation, mean_displacement
Evaluation    evaluate (EvaluationResult), compare, report
Projection    make_projection, UTM, LocalEquirectangular, utm_zone
Data          synthetic_city (SyntheticCity)
Backend       set_backend("numpy" | "scipy" | "auto"), get_backend
```

## References

* Allshouse, W. B., Fitch, M. K., Hampton, K. H., Gesink, D. C., Doherty,
  I. A., Leone, P. A., Serre, M. L., & Miller, W. C. (2010). Geomasking
  sensitive health data and privacy protection: an evaluation using an E911
  database. *Geocarto International*, 25(6), 443-452.
  https://doi.org/10.1080/10106049.2010.496496
* Armstrong, M. P., Rushton, G., & Zimmerman, D. L. (1999). Geographically
  masking health data to preserve confidentiality. *Statistics in Medicine*,
  18(5), 497-525.
* Hampton, K. H., Fitch, M. K., Allshouse, W. B., Doherty, I. A., Gesink,
  D. C., Leone, P. A., Serre, M. L., & Miller, W. C. (2010). Mapping health
  data: improved privacy protection with donut method geomasking. *American
  Journal of Epidemiology*, 172(9), 1062-1069.
  https://doi.org/10.1093/aje/kwq248
* Kounadi, O., & Leitner, M. (2016). Adaptive areal elimination (AAE): a
  transparent way of disclosing protected spatial datasets. *Computers,
  Environment and Urban Systems*, 57, 59-67.
  https://doi.org/10.1016/j.compenvurbsys.2016.01.004
* Zandbergen, P. A. (2014). Ensuring confidentiality of geocoded health data:
  assessing geographic masking strategies for individual-level data.
  *Advances in Medicine*, 2014, 567049. https://doi.org/10.1155/2014/567049
* Zhang, S., Freundschuh, S. M., Lenzer, K., & Zandbergen, P. A. (2017). The
  location swapping method for geomasking. *Cartography and Geographic
  Information Science*, 44(1), 22-34.
  https://doi.org/10.1080/15230406.2015.1095655

## Citing

If you use `geomask` in research, please cite it using the metadata in
[`CITATION.cff`](CITATION.cff) and cite the original method papers above.

## License

MIT (c) Yusuf Eminoğlu. See [LICENSE](LICENSE).
