scirs2_spatial/spatial_stats/
lisa.rs1use scirs2_core::ndarray::{Array1, Array2, ArrayView1, ArrayView2};
8use scirs2_core::random::{seeded_rng, Rng, RngExt, SeedableRng};
9
10use crate::error::{SpatialError, SpatialResult};
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum LisaCluster {
15 HighHigh,
17 LowLow,
19 HighLow,
21 LowHigh,
23 NotSignificant,
25}
26
27#[derive(Debug, Clone)]
29pub struct LisaResult {
30 pub local_i: Array1<f64>,
32 pub p_values: Array1<f64>,
34 pub clusters: Vec<LisaCluster>,
36}
37
38#[derive(Debug, Clone)]
40pub struct LisaClusterMap {
41 pub local_i: Array1<f64>,
43 pub p_values: Array1<f64>,
45 pub clusters: Vec<LisaCluster>,
47 pub mean: f64,
49 pub n_permutations: usize,
51 pub alpha: f64,
53}
54
55#[derive(Debug, Clone)]
57pub struct GetisOrdResult {
58 pub z_scores: Array1<f64>,
60 pub p_values: Array1<f64>,
62}
63
64pub fn local_moran_permutation_test(
86 values: &ArrayView1<f64>,
87 weights: &ArrayView2<f64>,
88 n_permutations: usize,
89 seed: u64,
90) -> SpatialResult<LisaResult> {
91 let n = values.len();
92 if weights.nrows() != n || weights.ncols() != n {
93 return Err(SpatialError::DimensionError(
94 "Weights matrix dimensions must match number of values".to_string(),
95 ));
96 }
97 if n < 3 {
98 return Err(SpatialError::ValueError(
99 "Need at least 3 observations".to_string(),
100 ));
101 }
102
103 let nf = n as f64;
104 let mean: f64 = values.sum() / nf;
105 let variance: f64 = values.iter().map(|&x| (x - mean) * (x - mean)).sum::<f64>() / nf;
106 if variance == 0.0 {
107 return Err(SpatialError::ValueError("Variance is zero".to_string()));
108 }
109 let std_dev = variance.sqrt();
110
111 let z: Vec<f64> = values.iter().map(|&x| (x - mean) / std_dev).collect();
113
114 let mut local_i = Array1::zeros(n);
116 for i in 0..n {
117 let mut wz_sum = 0.0;
118 for j in 0..n {
119 if i != j {
120 wz_sum += weights[[i, j]] * z[j];
121 }
122 }
123 local_i[i] = z[i] * wz_sum;
124 }
125
126 let mut p_values = Array1::zeros(n);
128 let mut rng = seeded_rng(seed);
129
130 let mut indices: Vec<usize> = (0..n).collect();
132
133 for i in 0..n {
134 let obs_i = local_i[i].abs();
135 let mut count_extreme = 0usize;
136
137 for _perm in 0..n_permutations {
138 fisher_yates_shuffle_except(&mut indices, i, &mut rng);
142
143 let mut wz_perm = 0.0;
144 for j in 0..n {
145 if j != i {
146 wz_perm += weights[[i, j]] * z[indices[j]];
148 }
149 }
150 let i_perm = z[i] * wz_perm;
151
152 if i_perm.abs() >= obs_i {
153 count_extreme += 1;
154 }
155 }
156
157 p_values[i] = (count_extreme as f64 + 1.0) / (n_permutations as f64 + 1.0);
158 }
159
160 let alpha = 0.05;
162 let clusters = classify_clusters(&z, &local_i, &p_values, weights, alpha);
163
164 Ok(LisaResult {
165 local_i,
166 p_values,
167 clusters,
168 })
169}
170
171pub fn lisa_cluster_map(
176 values: &ArrayView1<f64>,
177 weights: &ArrayView2<f64>,
178 n_permutations: usize,
179 alpha: f64,
180 seed: u64,
181) -> SpatialResult<LisaClusterMap> {
182 let n = values.len();
183 if weights.nrows() != n || weights.ncols() != n {
184 return Err(SpatialError::DimensionError(
185 "Weights matrix dimensions must match number of values".to_string(),
186 ));
187 }
188 if n < 3 {
189 return Err(SpatialError::ValueError(
190 "Need at least 3 observations".to_string(),
191 ));
192 }
193
194 let nf = n as f64;
195 let mean: f64 = values.sum() / nf;
196 let variance: f64 = values.iter().map(|&x| (x - mean) * (x - mean)).sum::<f64>() / nf;
197 if variance == 0.0 {
198 return Err(SpatialError::ValueError("Variance is zero".to_string()));
199 }
200 let std_dev = variance.sqrt();
201 let z: Vec<f64> = values.iter().map(|&x| (x - mean) / std_dev).collect();
202
203 let mut local_i = Array1::zeros(n);
204 for i in 0..n {
205 let mut wz_sum = 0.0;
206 for j in 0..n {
207 if i != j {
208 wz_sum += weights[[i, j]] * z[j];
209 }
210 }
211 local_i[i] = z[i] * wz_sum;
212 }
213
214 let mut p_values = Array1::zeros(n);
216 let mut rng = seeded_rng(seed);
217 let mut indices: Vec<usize> = (0..n).collect();
218
219 for i in 0..n {
220 let obs_i = local_i[i].abs();
221 let mut count_extreme = 0usize;
222
223 for _perm in 0..n_permutations {
224 fisher_yates_shuffle_except(&mut indices, i, &mut rng);
225
226 let mut wz_perm = 0.0;
227 for j in 0..n {
228 if j != i {
229 wz_perm += weights[[i, j]] * z[indices[j]];
230 }
231 }
232 let i_perm = z[i] * wz_perm;
233
234 if i_perm.abs() >= obs_i {
235 count_extreme += 1;
236 }
237 }
238
239 p_values[i] = (count_extreme as f64 + 1.0) / (n_permutations as f64 + 1.0);
240 }
241
242 let clusters = classify_clusters(&z, &local_i, &p_values, weights, alpha);
243
244 Ok(LisaClusterMap {
245 local_i,
246 p_values,
247 clusters,
248 mean,
249 n_permutations,
250 alpha,
251 })
252}
253
254pub fn getis_ord_gi_star(
266 values: &ArrayView1<f64>,
267 weights: &ArrayView2<f64>,
268) -> SpatialResult<GetisOrdResult> {
269 let n = values.len();
270 if weights.nrows() != n || weights.ncols() != n {
271 return Err(SpatialError::DimensionError(
272 "Weights matrix dimensions must match number of values".to_string(),
273 ));
274 }
275 if n < 2 {
276 return Err(SpatialError::ValueError(
277 "Need at least 2 observations".to_string(),
278 ));
279 }
280
281 let nf = n as f64;
282 let xbar: f64 = values.sum() / nf;
283 let s2: f64 = values.iter().map(|&x| (x - xbar) * (x - xbar)).sum::<f64>() / nf;
284 if s2 == 0.0 {
285 return Err(SpatialError::ValueError(
286 "Variance of values is zero".to_string(),
287 ));
288 }
289 let s = s2.sqrt();
290
291 let mut z_scores = Array1::zeros(n);
292 let mut p_values = Array1::zeros(n);
293
294 for i in 0..n {
295 let mut wx_sum = 0.0;
297 let mut w_sum = 0.0;
298 let mut w_sq_sum = 0.0;
299
300 for j in 0..n {
301 let wij = if i == j {
302 if weights[[i, j]] == 0.0 {
304 1.0
305 } else {
306 weights[[i, j]]
307 }
308 } else {
309 weights[[i, j]]
310 };
311 wx_sum += wij * values[j];
312 w_sum += wij;
313 w_sq_sum += wij * wij;
314 }
315
316 let numerator = wx_sum - xbar * w_sum;
317 let denom_inner = (nf * w_sq_sum - w_sum * w_sum) / (nf - 1.0);
318
319 if denom_inner > 0.0 {
320 let denominator = s * denom_inner.sqrt();
321 z_scores[i] = numerator / denominator;
322 }
323
324 p_values[i] = two_sided_p(z_scores[i]);
325 }
326
327 Ok(GetisOrdResult { z_scores, p_values })
328}
329
330fn two_sided_p(z: f64) -> f64 {
336 2.0 * (1.0 - normal_cdf(z.abs()))
337}
338
339fn normal_cdf(x: f64) -> f64 {
341 let a1 = 0.254829592;
342 let a2 = -0.284496736;
343 let a3 = 1.421413741;
344 let a4 = -1.453152027;
345 let a5 = 1.061405429;
346 let p = 0.3275911;
347
348 let sign = if x < 0.0 { -1.0 } else { 1.0 };
349 let x_abs = x.abs() / std::f64::consts::SQRT_2;
350 let t = 1.0 / (1.0 + p * x_abs);
351 let y = 1.0 - (((((a5 * t + a4) * t) + a3) * t + a2) * t + a1) * t * (-x_abs * x_abs).exp();
352 0.5 * (1.0 + sign * y)
353}
354
355fn fisher_yates_shuffle_except<R: Rng + ?Sized>(indices: &mut [usize], skip: usize, rng: &mut R) {
357 let n = indices.len();
358 for (idx, val) in indices.iter_mut().enumerate() {
360 *val = idx;
361 }
362
363 let positions: Vec<usize> = (0..n).filter(|&p| p != skip).collect();
366 let m = positions.len();
367 for k in (1..m).rev() {
368 let j = rng.random_range(0..=k);
369 indices.swap(positions[k], positions[j]);
370 }
371}
372
373fn classify_clusters(
375 z: &[f64],
376 local_i: &Array1<f64>,
377 p_values: &Array1<f64>,
378 weights: &ArrayView2<f64>,
379 alpha: f64,
380) -> Vec<LisaCluster> {
381 let n = z.len();
382 let mut clusters = vec![LisaCluster::NotSignificant; n];
383
384 for i in 0..n {
385 if p_values[i] > alpha {
386 continue;
387 }
388
389 let mut w_sum = 0.0;
391 let mut wz_sum = 0.0;
392 for j in 0..n {
393 if j != i && weights[[i, j]] > 0.0 {
394 w_sum += weights[[i, j]];
395 wz_sum += weights[[i, j]] * z[j];
396 }
397 }
398
399 let lag = if w_sum > 0.0 { wz_sum / w_sum } else { 0.0 };
400
401 clusters[i] = if z[i] > 0.0 && lag > 0.0 {
402 LisaCluster::HighHigh
403 } else if z[i] < 0.0 && lag < 0.0 {
404 LisaCluster::LowLow
405 } else if z[i] > 0.0 && lag < 0.0 {
406 LisaCluster::HighLow
407 } else if z[i] < 0.0 && lag > 0.0 {
408 LisaCluster::LowHigh
409 } else {
410 LisaCluster::NotSignificant
411 };
412 }
413
414 clusters
415}
416
417#[cfg(test)]
422mod tests {
423 use super::*;
424 use scirs2_core::ndarray::array;
425
426 fn build_chain_weights(n: usize) -> Array2<f64> {
427 let mut w = Array2::zeros((n, n));
428 for i in 0..(n - 1) {
429 w[[i, i + 1]] = 1.0;
430 w[[i + 1, i]] = 1.0;
431 }
432 w
433 }
434
435 #[test]
436 fn test_local_moran_permutation_hot_spot() {
437 let values = array![10.0, 10.0, 10.0, 1.0, 1.0, 1.0];
439 let w = build_chain_weights(6);
440
441 let result =
442 local_moran_permutation_test(&values.view(), &w.view(), 199, 42).expect("lisa failed");
443
444 assert_eq!(result.local_i.len(), 6);
445 assert_eq!(result.p_values.len(), 6);
446 assert_eq!(result.clusters.len(), 6);
447
448 assert!(
451 result.local_i[0] > 0.0,
452 "Local I at position 0 should be positive for clustered high values"
453 );
454 }
455
456 #[test]
457 fn test_local_moran_permutation_spatial_outlier() {
458 let values = array![1.0, 1.0, 10.0, 1.0, 1.0];
460 let w = build_chain_weights(5);
461
462 let result =
463 local_moran_permutation_test(&values.view(), &w.view(), 199, 123).expect("lisa");
464
465 assert!(
467 result.local_i[2] < 0.0,
468 "Local I at outlier position should be negative, got {}",
469 result.local_i[2]
470 );
471 }
472
473 #[test]
474 fn test_lisa_cluster_map_classifications() {
475 let values = array![10.0, 10.0, 10.0, 1.0, 1.0, 1.0, 1.0, 1.0];
476 let w = build_chain_weights(8);
477
478 let map = lisa_cluster_map(&values.view(), &w.view(), 499, 0.10, 42).expect("cluster_map");
479
480 assert_eq!(map.clusters.len(), 8);
481 assert_eq!(map.n_permutations, 499);
482
483 let has_hh = map.clusters.contains(&LisaCluster::HighHigh);
485 let has_ll = map.clusters.contains(&LisaCluster::LowLow);
486 let has_ns = map.clusters.contains(&LisaCluster::NotSignificant);
487
488 assert!(
491 has_hh || has_ll || has_ns,
492 "Should produce at least one classification"
493 );
494 }
495
496 #[test]
497 fn test_getis_ord_gi_star_hotspot() {
498 let values = array![10.0, 10.0, 10.0, 1.0, 1.0, 1.0];
500 let w = build_chain_weights(6);
501
502 let result = getis_ord_gi_star(&values.view(), &w.view()).expect("gi_star");
503
504 assert_eq!(result.z_scores.len(), 6);
505 assert_eq!(result.p_values.len(), 6);
506
507 assert!(
509 result.z_scores[0] > 0.0,
510 "z-score at position 0 should be positive for high cluster, got {}",
511 result.z_scores[0]
512 );
513
514 assert!(
516 result.z_scores[5] < 0.0,
517 "z-score at position 5 should be negative for low cluster, got {}",
518 result.z_scores[5]
519 );
520 }
521
522 #[test]
523 fn test_getis_ord_gi_star_uniform() {
524 let values = array![5.0, 5.0, 5.0, 5.0, 5.0];
526 let w = build_chain_weights(5);
527
528 let result = getis_ord_gi_star(&values.view(), &w.view());
530 assert!(result.is_err());
531 }
532
533 #[test]
534 fn test_getis_ord_gi_star_p_values() {
535 let values = array![100.0, 100.0, 100.0, 1.0, 1.0, 1.0, 1.0, 1.0];
536 let w = build_chain_weights(8);
537
538 let result = getis_ord_gi_star(&values.view(), &w.view()).expect("gi_star");
539
540 for &p in result.p_values.iter() {
542 assert!((0.0..=1.0).contains(&p), "p-value {} out of range", p);
543 }
544
545 assert!(
547 result.p_values[0] < 0.5,
548 "p-value at hotspot should be < 0.5"
549 );
550 }
551}