neurospatial.stats¶
stats
¶
Statistical methods.
This module provides general-purpose statistical utilities used across encoding, decoding, and behavior analysis.
Submodules
circular : Circular statistics, Rayleigh test shuffle : Shuffle controls, permutation tests surrogates : Surrogate data generation
Imports
from neurospatial.stats import rayleigh_test, circular_mean from neurospatial.stats import shuffle_time_bins, compute_shuffle_pvalue from neurospatial.stats import generate_poisson_surrogates, generate_jittered_spikes from neurospatial.stats.circular import circular_basis from neurospatial.stats.shuffle import ShuffleTestResult from neurospatial.stats.surrogates import generate_inhomogeneous_poisson_surrogates
Classes¶
CircularBasisResult
dataclass
¶
CircularBasisResult(sin_component: NDArray[float64], cos_component: NDArray[float64], angles: NDArray[float64])
Result from circular_basis() function.
Contains sin/cos components for use in GLM design matrices, plus the original angles for reference.
Attributes:
| Name | Type | Description |
|---|---|---|
sin_component |
(ndarray, shape(n_samples))
|
Sine of angles: sin(angles). |
cos_component |
(ndarray, shape(n_samples))
|
Cosine of angles: cos(angles). |
angles |
(ndarray, shape(n_samples))
|
Original angles (in radians). |
Properties
design_matrix : ndarray, shape (n_samples, 2) Design matrix with columns [sin_component, cos_component].
Examples:
ShuffleTestResult
dataclass
¶
ShuffleTestResult(observed_score: float, null_scores: NDArray[float64], p_value: float, z_score: float, shuffle_type: str, n_shuffles: int)
Result container for shuffle-based significance testing.
A frozen dataclass containing the observed score, null distribution, and computed statistics (p-value, z-score) from a shuffle test.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
observed_score
|
float
|
The score computed from the original (non-shuffled) data. |
required |
null_scores
|
NDArray[float64]
|
Array of scores computed from shuffled data, forming the null distribution. |
required |
p_value
|
float
|
Monte Carlo p-value with correction: (k + 1) / (n + 1) where k is the count of null scores at least as extreme as observed and n is the number of shuffles. |
required |
z_score
|
float
|
Standard score: (observed - mean(null)) / std(null). NaN if null has zero variance. |
required |
shuffle_type
|
str
|
Name of the shuffle method used (e.g., "time_bins", "cell_identity"). |
required |
n_shuffles
|
int
|
Number of shuffles performed. |
required |
Attributes:
| Name | Type | Description |
|---|---|---|
is_significant |
bool
|
True if p_value < 0.05 (convenience property). |
Examples:
>>> result = ShuffleTestResult(
... observed_score=5.0,
... null_scores=np.array([1.0, 2.0, 3.0, 4.0]),
... p_value=0.2,
... z_score=1.5,
... shuffle_type="time_bins",
... n_shuffles=4,
... )
>>> result.is_significant
False
>>> result.p_value
0.2
See Also
compute_shuffle_pvalue : Compute Monte Carlo p-value from null distribution. compute_shuffle_zscore : Compute z-score from null distribution.
Attributes¶
is_significant
property
¶
Return True if p_value < 0.05.
Returns:
| Type | Description |
|---|---|
bool
|
True if the result is statistically significant at alpha=0.05. |
Functions¶
plot
¶
Plot the null distribution with observed score.
Creates a histogram of the null distribution with a vertical line showing the observed score and annotations for p-value and z-score.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
ax
|
Axes | None
|
Axes to plot on. If None, creates a new figure. |
None
|
**kwargs
|
Additional keyword arguments passed to matplotlib's hist(). |
{}
|
Returns:
| Type | Description |
|---|---|
Axes
|
The axes containing the plot. |
Examples:
>>> result = ShuffleTestResult(
... observed_score=5.0,
... null_scores=np.random.default_rng(42).normal(2.0, 1.0, 100),
... p_value=0.01,
... z_score=3.0,
... shuffle_type="time_bins",
... n_shuffles=100,
... )
>>> ax = result.plot()
Source code in src/neurospatial/stats/shuffle.py
Functions¶
circular_basis
¶
circular_basis(angles: NDArray[float64], *, angle_unit: Literal['rad', 'deg'] = 'rad') -> CircularBasisResult
Compute sine/cosine basis functions for circular variables.
Creates a design matrix for use in GLMs (Generalized Linear Models) where the sin and cos components capture circular modulation. This is the standard approach for including circular predictors in regression models.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
angles
|
(array, shape(n_samples))
|
Circular variable. Common use cases:
|
required |
angle_unit
|
('rad', 'deg')
|
Unit of input angles. Use |
'rad'
|
Returns:
| Type | Description |
|---|---|
CircularBasisResult
|
Result object containing:
|
See Also
circular_basis_metrics : Compute amplitude/phase from GLM coefficients. is_modulated : Quick significance test for circular modulation. plot_circular_basis_tuning : Visualize fitted tuning curve.
Notes
Why sin/cos basis?
For a circular predictor θ, using sin(θ) and cos(θ) as separate predictors allows the model to capture any phase and amplitude of circular modulation. The fitted coefficients (β_sin, β_cos) can be converted to:
- Amplitude: sqrt(β_sin² + β_cos²) — modulation strength
- Phase: atan2(β_sin, β_cos) — preferred direction/phase
GLM vs. Binned Tuning Curves:
- Binned: Non-parametric, intuitive, good for visualization. Requires many spikes per bin.
- GLM (
circular_basis): Parametric, statistically rigorous, can include covariates. Better for low spike counts.
Examples:
Head direction tuning (GLM approach):
>>> import numpy as np
>>> from neurospatial.stats.circular import circular_basis
>>> # Head direction at each spike (in degrees)
>>> hd_at_spikes = np.array([45, 50, 48, 52, 180, 185, 170]) # degrees
>>> result = circular_basis(hd_at_spikes, angle_unit="deg")
>>> result.design_matrix.shape
(7, 2)
Theta phase modulation:
>>> # LFP phase at each spike (in radians)
>>> theta_phases = np.random.uniform(0, 2 * np.pi, 100)
>>> result = circular_basis(theta_phases) # angle_unit='rad' is default
>>> X = result.design_matrix
>>> # Now fit GLM: model.fit(sm.add_constant(X), spike_counts)
Source code in src/neurospatial/stats/circular.py
1341 1342 1343 1344 1345 1346 1347 1348 1349 1350 1351 1352 1353 1354 1355 1356 1357 1358 1359 1360 1361 1362 1363 1364 1365 1366 1367 1368 1369 1370 1371 1372 1373 1374 1375 1376 1377 1378 1379 1380 1381 1382 1383 1384 1385 1386 1387 1388 1389 1390 1391 1392 1393 1394 1395 1396 1397 1398 1399 1400 1401 1402 1403 1404 1405 1406 1407 1408 1409 1410 1411 1412 1413 1414 1415 1416 1417 1418 1419 1420 1421 1422 1423 1424 1425 1426 1427 1428 1429 1430 1431 1432 1433 | |
circular_basis_metrics
¶
circular_basis_metrics(beta_sin: float, beta_cos: float, cov_matrix: NDArray[float64] | None = None) -> tuple[float, float, float | None]
Compute amplitude, phase, and p-value from GLM coefficients.
Given fitted coefficients for sin and cos basis functions, compute: - Amplitude (strength of modulation) - Phase (preferred angle) - P-value (statistical significance via Wald test)
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
beta_sin
|
float
|
Coefficient for sin(angle) from fitted GLM. |
required |
beta_cos
|
float
|
Coefficient for cos(angle) from fitted GLM. |
required |
cov_matrix
|
(ndarray, shape(2, 2))
|
Covariance matrix for [beta_sin, beta_cos] from GLM fit. If provided, computes p-value via Wald test. |
None
|
Returns:
| Name | Type | Description |
|---|---|---|
amplitude |
float
|
Modulation amplitude: sqrt(beta_sin^2 + beta_cos^2). Larger values indicate stronger circular modulation. |
phase |
float
|
Preferred phase angle in radians, range [-π, π]. Computed as atan2(beta_sin, beta_cos). |
pvalue |
float or None
|
P-value testing H0: amplitude = 0 (no modulation). None if cov_matrix not provided. |
See Also
circular_basis : Create design matrix for GLM.
Notes
Interpretation:
- amplitude > 0.3 typically indicates meaningful modulation
- pvalue < 0.05 indicates statistically significant modulation
- phase tells you the preferred angle (e.g., preferred head direction)
Examples:
>>> import numpy as np
>>> from neurospatial.stats.circular import circular_basis_metrics
>>> # After fitting GLM with circular basis
>>> beta_sin, beta_cos = 0.5, 0.3
>>> cov = np.array([[0.01, 0.001], [0.001, 0.01]])
>>> amplitude, phase, pval = circular_basis_metrics(beta_sin, beta_cos, cov)
>>> print(f"Amplitude: {amplitude:.2f}, Phase: {np.degrees(phase):.1f}°")
Amplitude: 0.58, Phase: 59.0°
Source code in src/neurospatial/stats/circular.py
1436 1437 1438 1439 1440 1441 1442 1443 1444 1445 1446 1447 1448 1449 1450 1451 1452 1453 1454 1455 1456 1457 1458 1459 1460 1461 1462 1463 1464 1465 1466 1467 1468 1469 1470 1471 1472 1473 1474 1475 1476 1477 1478 1479 1480 1481 1482 1483 1484 1485 1486 1487 1488 1489 1490 1491 1492 1493 1494 1495 1496 1497 1498 1499 1500 1501 1502 1503 1504 1505 1506 1507 1508 | |
circular_circular_correlation
¶
circular_circular_correlation(angles1: NDArray[float64], angles2: NDArray[float64], *, angle_unit: Literal['rad', 'deg'] = 'rad') -> tuple[float, float]
Circular-circular correlation coefficient.
Computes the Fisher & Lee (1983) circular correlation coefficient between two circular variables.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
angles1
|
ndarray of shape (n_samples,)
|
First circular variable in radians or degrees. |
required |
angles2
|
ndarray of shape (n_samples,)
|
Second circular variable in radians or degrees. Must have same
length as |
required |
angle_unit
|
('rad', 'deg')
|
Unit of input angles (applies to both arrays). |
'rad'
|
Returns:
| Name | Type | Description |
|---|---|---|
r |
float
|
Circular correlation coefficient in [-1, 1]. - r > 0: positive association (angles increase together) - r < 0: negative association (angles move in opposite directions) - r = 0: no circular correlation |
pval |
float
|
P-value from normal approximation. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If arrays have different lengths or insufficient samples. |
See Also
circular_linear_correlation : For circular-linear correlation.
Notes
Formula (Fisher & Lee, 1983; Jammalamadaka & SenGupta, 2001, p. 176):
rho = sum(sin(a1 - mean1) * sin(a2 - mean2)) /
sqrt(sum(sin(a1 - mean1)^2) * sum(sin(a2 - mean2)^2))
Interpretation:
- |r| > 0.3: Moderate association
- |r| > 0.5: Strong association
Important Properties:
- Symmetric: r(a1, a2) == r(a2, a1)
- Invariant to constant offsets: r(a, a + c) = 1.0 for any constant c (because deviations from circular means remain identical)
- Anticorrelation requires reflection: r(a, -a) approaches -1.0
Examples:
>>> import numpy as np
>>> from neurospatial.stats.circular import circular_circular_correlation
>>> angles1 = np.random.default_rng(42).uniform(0, 2 * np.pi, 100)
>>> angles2 = angles1 + 0.1 * np.random.default_rng(42).standard_normal(100)
>>> r, p = circular_circular_correlation(angles1, angles2)
>>> r > 0.8
True
References
Fisher, N.I. & Lee, A.J. (1983). A correlation coefficient for circular data. Biometrika, 70(2), 327-332. Jammalamadaka, S.R. & SenGupta, A. (2001). Topics in Circular Statistics. World Scientific, p. 176.
Source code in src/neurospatial/stats/circular.py
877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 | |
circular_linear_correlation
¶
circular_linear_correlation(angles: NDArray[float64], linear_values: NDArray[float64], *, angle_unit: Literal['rad', 'deg'] = 'rad') -> tuple[float, float]
Circular-linear correlation coefficient.
Computes the correlation between a circular and a linear variable using the Mardia & Jupp formula.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
angles
|
ndarray of shape (n_samples,)
|
Circular variable (e.g., spike phases) in radians or degrees. |
required |
linear_values
|
ndarray of shape (n_samples,)
|
Linear variable (e.g., positions, time). |
required |
angle_unit
|
('rad', 'deg')
|
Unit of input angles. |
'rad'
|
Returns:
| Name | Type | Description |
|---|---|---|
r |
float
|
Correlation coefficient in [0, 1]. Note: always non-negative. |
pval |
float
|
P-value from chi-squared distribution with 2 df. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If arrays have different lengths or insufficient samples. |
See Also
phase_position_correlation : Alias with neuroscience naming. circular_circular_correlation : For two circular variables.
Notes
The circular-linear correlation is computed using the formula from Mardia & Jupp (2000, Section 11.3):
r^2 = (r_xs^2 + r_xc^2 - 2*r_xs*r_xc*r_cs) / (1 - r_cs^2)
where: - r_xs: Pearson correlation between linear variable x and sin(angles) - r_xc: Pearson correlation between linear variable x and cos(angles) - r_cs: Pearson correlation between cos(angles) and sin(angles)
The result is always non-negative because r is the square root of r^2.
Examples:
>>> import numpy as np
>>> from neurospatial.stats.circular import circular_linear_correlation
>>> positions = np.linspace(0, 100, 50)
>>> phases = np.linspace(0, 2 * np.pi, 50) # Linear relationship
>>> r, p = circular_linear_correlation(phases, positions)
>>> r > 0.5 and p < 0.05 # Significant correlation
True
Source code in src/neurospatial/stats/circular.py
708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 | |
circular_mean
¶
circular_mean(angles: NDArray[float64], *, angle_unit: Literal['rad', 'deg'] = 'rad', weights: NDArray[float64] | None = None) -> float
Compute the circular mean (mean direction) of angles.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
angles
|
ndarray of shape (n_samples,)
|
Sample of angles. |
required |
angle_unit
|
('rad', 'deg')
|
Unit of input angles. |
'rad'
|
weights
|
ndarray of shape (n_samples,)
|
Weights for each angle. If None, uniform weights are used.
Must be the same length as |
None
|
Returns:
| Type | Description |
|---|---|
float
|
Circular mean in radians, range [-π, π]. |
Examples:
>>> import numpy as np
>>> from neurospatial.stats.circular import circular_mean
>>> angles = np.array([0, np.pi / 4, np.pi / 2])
>>> mean = circular_mean(angles)
>>> bool(np.abs(mean - np.pi / 4) < 0.1)
True
Source code in src/neurospatial/stats/circular.py
1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 | |
circular_variance
¶
circular_variance(angles: NDArray[float64], *, angle_unit: Literal['rad', 'deg'] = 'rad', weights: NDArray[float64] | None = None) -> float
Compute the circular variance of angles.
The circular variance is defined as V = 1 - R, where R is the mean resultant length.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
angles
|
ndarray of shape (n_samples,)
|
Sample of angles. |
required |
angle_unit
|
('rad', 'deg')
|
Unit of input angles. |
'rad'
|
weights
|
ndarray of shape (n_samples,)
|
Weights for each angle. If None, uniform weights are used.
Must be the same length as |
None
|
Returns:
| Type | Description |
|---|---|
float
|
Circular variance in [0, 1]. 0 indicates all angles are identical, 1 indicates maximum dispersion (uniform distribution). |
Examples:
>>> import numpy as np
>>> from neurospatial.stats.circular import circular_variance
>>> # Concentrated distribution - low variance
>>> concentrated = np.array([0.1, 0.15, 0.05, 0.12])
>>> var = circular_variance(concentrated)
>>> var < 0.1
True
Source code in src/neurospatial/stats/circular.py
is_modulated
¶
is_modulated(beta_sin: float, beta_cos: float, cov_matrix: NDArray[float64], *, alpha: float = 0.05, min_magnitude: float = 0.2) -> bool
Quick check: Do GLM coefficients show significant circular modulation?
This is a convenience function that combines statistical significance (Wald test p-value) with a practical magnitude threshold.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
beta_sin
|
float
|
Coefficient for sin(angle) from fitted GLM. |
required |
beta_cos
|
float
|
Coefficient for cos(angle) from fitted GLM. |
required |
cov_matrix
|
(ndarray, shape(2, 2))
|
Covariance matrix for [beta_sin, beta_cos] from GLM fit. Required for significance testing. |
required |
alpha
|
float
|
Significance level for Wald test. |
0.05
|
min_magnitude
|
float
|
Minimum modulation magnitude (amplitude) required. Amplitude = sqrt(beta_sin^2 + beta_cos^2). Default justification (0.2): This threshold provides a balance between sensitivity and specificity for detecting biologically meaningful circular modulation in neural data. Based on typical GLM coefficient scales for head direction cells and phase precession:
The 0.2 threshold filters out very weak effects while maintaining sensitivity to moderate modulation. Adjust based on your data scale and scientific question. |
0.2
|
Returns:
| Type | Description |
|---|---|
bool
|
True if BOTH: - p-value < alpha (statistically significant) - magnitude >= min_magnitude (practically meaningful) False otherwise. |
See Also
circular_basis_metrics : For full analysis with all metrics.
Notes
Why both thresholds?
Statistical significance alone can occur with very weak modulation when sample sizes are large. Requiring both significance AND a minimum magnitude ensures the modulation is both reliable and biologically meaningful.
Examples:
>>> import numpy as np
>>> from neurospatial.stats.circular import is_modulated
>>> # Strong significant modulation
>>> is_modulated(2.0, 2.0, np.array([[0.01, 0], [0, 0.01]]))
True
>>> # Weak (non-significant) modulation
>>> is_modulated(0.1, 0.1, np.array([[1.0, 0], [0, 1.0]]))
False
Source code in src/neurospatial/stats/circular.py
1511 1512 1513 1514 1515 1516 1517 1518 1519 1520 1521 1522 1523 1524 1525 1526 1527 1528 1529 1530 1531 1532 1533 1534 1535 1536 1537 1538 1539 1540 1541 1542 1543 1544 1545 1546 1547 1548 1549 1550 1551 1552 1553 1554 1555 1556 1557 1558 1559 1560 1561 1562 1563 1564 1565 1566 1567 1568 1569 1570 1571 1572 1573 1574 1575 1576 1577 1578 1579 1580 1581 1582 1583 1584 1585 1586 1587 1588 1589 1590 1591 | |
mean_resultant_length
¶
mean_resultant_length(angles: NDArray[float64], *, angle_unit: Literal['rad', 'deg'] = 'rad', weights: NDArray[float64] | None = None) -> float
Compute the mean resultant length (Rayleigh vector length).
The mean resultant length R measures concentration of circular data. It equals 1 if all angles are identical and approaches 0 for uniform distributions.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
angles
|
ndarray of shape (n_samples,)
|
Sample of angles. |
required |
angle_unit
|
('rad', 'deg')
|
Unit of input angles. |
'rad'
|
weights
|
ndarray of shape (n_samples,)
|
Weights for each angle. If None, uniform weights are used.
Must be the same length as |
None
|
Returns:
| Type | Description |
|---|---|
float
|
Mean resultant length R in [0, 1]. |
Notes
The mean resultant length is defined as:
R = |mean(exp(i * angles))| = sqrt(mean(cos)^2 + mean(sin)^2)
Examples:
>>> import numpy as np
>>> from neurospatial.stats.circular import mean_resultant_length
>>> # All angles identical - R should be 1
>>> r = mean_resultant_length(np.array([0, 0, 0, 0]))
>>> bool(np.abs(r - 1.0) < 0.01)
True
Source code in src/neurospatial/stats/circular.py
phase_position_correlation
¶
phase_position_correlation(phases: NDArray[float64], positions: NDArray[float64], *, angle_unit: Literal['rad', 'deg'] = 'rad') -> tuple[float, float]
Correlation between spike phases and positions.
This is an alias for circular_linear_correlation with neuroscience
naming conventions.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
phases
|
ndarray of shape (n_spikes,)
|
Spike phases relative to LFP theta in radians or degrees. |
required |
positions
|
ndarray of shape (n_spikes,)
|
Position at each spike. |
required |
angle_unit
|
('rad', 'deg')
|
Unit of input phases. |
'rad'
|
Returns:
| Name | Type | Description |
|---|---|---|
r |
float
|
Correlation coefficient in [0, 1]. |
pval |
float
|
P-value from chi-squared distribution. |
See Also
circular_linear_correlation : Generic version. phase_precession : Full phase precession analysis with slope.
Source code in src/neurospatial/stats/circular.py
plot_circular_basis_tuning
¶
plot_circular_basis_tuning(beta_sin: float, beta_cos: float, angles: NDArray[float64] | None = None, rates: NDArray[float64] | None = None, ax: Axes | PolarAxes | None = None, *, intercept: float = 0.0, cov_matrix: NDArray[float64] | None = None, projection: Literal['polar', 'linear'] = 'polar', n_points: int = 100, show_data: bool = False, show_fit: bool = True, show_ci: bool = False, ci: float = 0.95, color: str = 'C0', data_color: str = 'gray', data_alpha: float = 0.5, ci_alpha: float = 0.3, line_kwargs: dict[str, Any] | None = None, scatter_kwargs: dict[str, Any] | None = None) -> Axes | PolarAxes
Plot tuning curve from GLM circular basis coefficients.
Visualizes the fitted circular tuning curve from a GLM with sin/cos basis functions, optionally overlaying raw binned data.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
beta_sin
|
float
|
Coefficient for sin(angle) from fitted GLM. |
required |
beta_cos
|
float
|
Coefficient for cos(angle) from fitted GLM. |
required |
angles
|
(array, shape(n_bins))
|
Center of each angular bin (radians) for raw data overlay.
Required if |
None
|
rates
|
(array, shape(n_bins))
|
Firing rate or response in each bin.
Required if |
None
|
ax
|
Axes
|
Axes to plot on. If None, creates new figure with appropriate projection. |
None
|
intercept
|
float
|
Intercept (baseline) coefficient from GLM. For Poisson GLM, this controls the baseline firing rate: exp(intercept). |
0.0
|
cov_matrix
|
(ndarray, shape(2, 2))
|
Covariance matrix for [beta_sin, beta_cos] from GLM fit.
Required if |
None
|
projection
|
('polar', 'linear')
|
Plot projection type. |
'polar'
|
n_points
|
int
|
Number of points for smooth fitted curve. |
100
|
show_data
|
bool
|
If True, overlay raw data points. Requires |
False
|
show_fit
|
bool
|
If True, show smooth curve from GLM fit. |
True
|
show_ci
|
bool
|
If True, show confidence band around fitted curve. Requires |
False
|
ci
|
float
|
Confidence level for band (e.g., 0.95 for 95% CI). |
0.95
|
color
|
str
|
Color for fitted curve. |
'C0'
|
data_color
|
str
|
Color for data points. |
'gray'
|
data_alpha
|
float
|
Alpha (transparency) for data points. |
0.5
|
ci_alpha
|
float
|
Alpha (transparency) for confidence band fill. |
0.3
|
line_kwargs
|
dict
|
Additional kwargs for line plot (fitted curve). |
None
|
scatter_kwargs
|
dict
|
Additional kwargs for scatter plot (data points). |
None
|
Returns:
| Type | Description |
|---|---|
Axes
|
The axes object with the plot. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
See Also
circular_basis_metrics : Compute amplitude/phase from coefficients. circular_basis : Create design matrix for GLM.
Examples:
Head direction GLM tuning curve:
>>> import numpy as np
>>> from neurospatial.stats.circular import plot_circular_basis_tuning
>>> # After fitting GLM to head direction data
>>> # Coefficients: preferred direction ~45° with strong modulation
>>> beta_sin, beta_cos = 0.7, 0.7 # atan2(0.7, 0.7) ≈ 45°
>>> ax = plot_circular_basis_tuning(
... beta_sin, beta_cos, intercept=2.0
... )
Source code in src/neurospatial/stats/circular.py
1594 1595 1596 1597 1598 1599 1600 1601 1602 1603 1604 1605 1606 1607 1608 1609 1610 1611 1612 1613 1614 1615 1616 1617 1618 1619 1620 1621 1622 1623 1624 1625 1626 1627 1628 1629 1630 1631 1632 1633 1634 1635 1636 1637 1638 1639 1640 1641 1642 1643 1644 1645 1646 1647 1648 1649 1650 1651 1652 1653 1654 1655 1656 1657 1658 1659 1660 1661 1662 1663 1664 1665 1666 1667 1668 1669 1670 1671 1672 1673 1674 1675 1676 1677 1678 1679 1680 1681 1682 1683 1684 1685 1686 1687 1688 1689 1690 1691 1692 1693 1694 1695 1696 1697 1698 1699 1700 1701 1702 1703 1704 1705 1706 1707 1708 1709 1710 1711 1712 1713 1714 1715 1716 1717 1718 1719 1720 1721 1722 1723 1724 1725 1726 1727 1728 1729 1730 1731 1732 1733 1734 1735 1736 1737 1738 1739 1740 1741 1742 1743 1744 1745 1746 1747 1748 1749 1750 1751 1752 1753 1754 1755 1756 1757 1758 1759 1760 1761 1762 1763 1764 1765 1766 1767 1768 1769 1770 1771 1772 1773 1774 1775 1776 1777 1778 1779 1780 1781 1782 1783 1784 1785 1786 1787 1788 1789 1790 1791 1792 1793 1794 1795 1796 1797 1798 1799 1800 1801 1802 1803 1804 1805 1806 1807 1808 1809 1810 1811 1812 1813 1814 1815 1816 1817 1818 1819 1820 1821 1822 1823 1824 1825 1826 1827 1828 1829 1830 1831 1832 1833 1834 1835 1836 1837 1838 1839 1840 1841 1842 1843 1844 1845 1846 1847 1848 1849 1850 | |
rayleigh_test
¶
rayleigh_test(angles: NDArray[float64], *, angle_unit: Literal['rad', 'deg'] = 'rad', weights: NDArray[float64] | None = None) -> tuple[float, float]
Rayleigh test for non-uniformity of circular data.
Tests H0: angles are uniformly distributed on the circle. Rejection indicates a preferred direction exists.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
angles
|
ndarray of shape (n_samples,)
|
Sample of angles. Values outside [0, 2pi] (or [0, 360] for degrees) will be wrapped. |
required |
angle_unit
|
('rad', 'deg')
|
Unit of input angles. |
'rad'
|
weights
|
ndarray of shape (n_samples,)
|
Weights for each angle, interpreted as counts/frequencies
(e.g., spike counts per phase bin). If None, uniform unit weights
are used. Under this count convention, weighting an angle by an
integer |
None
|
Returns:
| Name | Type | Description |
|---|---|---|
z |
float
|
Rayleigh z-statistic (n * R^2 where R is mean resultant length). Range: [0, n]. Higher values indicate stronger directionality. |
pval |
float
|
P-value from Rayleigh approximation with finite-sample correction. Small p-values (< 0.05) indicate non-uniform distribution. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If angles array is empty, all NaN, or too short; if |
See Also
circular_linear_correlation : Test correlation with linear variable.
Notes
The unweighted statistic is z = n * R**2 where R is the mean
resultant length and n the number of angles. A finite-sample
correction from Mardia & Jupp (2000, Section 5.3.2, p. 94) is applied
for accurate p-values when the (effective) sample size is < 50.
Weighted variant. When weights are supplied this implements the
count-based (grouped-data) weighted Rayleigh test of Mardia & Jupp
(2000, Section 5.3.5): weights are treated as frequencies, the resultant
length is the weighted mean resultant length
R = |sum_i w_i exp(i theta_i)| / sum_i w_i, and the statistic is
z = (sum_i w_i) * R**2 with the finite-sample correction evaluated
at n = sum_i w_i. With unit weights this reduces exactly to the
unweighted statistic, and integer weights match physically replicating
each angle by its weight. This is intentionally not the
effective-sample-size form n_eff = (sum w)**2 / sum(w**2).
Examples:
>>> import numpy as np
>>> from neurospatial.stats.circular import rayleigh_test
>>> # Uniform distribution - expect high p-value
>>> uniform_angles = np.linspace(0, 2 * np.pi, 100, endpoint=False)
>>> z, p = rayleigh_test(uniform_angles)
>>> p > 0.5
True
Source code in src/neurospatial/stats/circular.py
551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 | |
wrap_angle
¶
Wrap angle to (-π, π].
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
angle
|
NDArray[float64]
|
Angles in radians. |
required |
Returns:
| Type | Description |
|---|---|
NDArray[float64]
|
Angles wrapped to (-π, π]. |
Examples:
>>> import numpy as np
>>> from neurospatial.stats.circular import wrap_angle
>>> wrap_angle(np.array([3 * np.pi / 2]))
array([-1.57079633])
Source code in src/neurospatial/stats/circular.py
compute_shuffle_pvalue
¶
compute_shuffle_pvalue(observed: float, null_scores: NDArray[float64], *, tail: Literal['greater', 'less', 'two-sided'] = 'greater') -> float
Compute Monte Carlo p-value with Phipson-Smyth correction.
Computes the probability of observing a score at least as extreme as the observed value under the null distribution using the formula (k + 1) / (n + 1), which provides an unbiased estimate and avoids p-values of exactly zero.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
observed
|
float
|
The observed score from the original (non-shuffled) data. |
required |
null_scores
|
NDArray[float64]
|
Array of scores from shuffled data, forming the null distribution. |
required |
tail
|
('greater', 'less', 'two-sided')
|
Direction of the test:
|
"greater"
|
Returns:
| Type | Description |
|---|---|
float
|
The Monte Carlo p-value in the range (0, 1]. |
Notes
The Phipson-Smyth correction (k + 1) / (n + 1) ensures that:
- The p-value is never exactly zero, which is important for downstream corrections (e.g., Bonferroni, FDR).
- The estimator is unbiased for uniformly distributed test statistics.
For replay analysis, "greater" is typically used because we expect significant sequences to have higher scores than shuffled controls.
Non-finite null scores (NaN or Inf) are dropped with a UserWarning
and excluded from n before the p-value is computed; otherwise they
would silently bias the p-value toward significance. If no finite null
scores remain, a ValueError is raised.
References
.. [1] Phipson, B., & Smyth, G. K. (2010). Permutation P-values should never be zero: calculating exact P-values when permutations are randomly drawn. Statistical Applications in Genetics and Molecular Biology, 9(1).
Examples:
>>> # Observed score higher than all null values
>>> observed = 10.0
>>> null = np.array([1.0, 2.0, 3.0, 4.0])
>>> compute_shuffle_pvalue(observed, null, tail="greater")
0.2
See Also
compute_shuffle_zscore : Compute z-score from null distribution. ShuffleTestResult : Container for shuffle test results.
Source code in src/neurospatial/stats/shuffle.py
946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 | |
compute_shuffle_zscore
¶
Compute z-score of observed value relative to null distribution.
Calculates the standard score: (observed - mean(null)) / std(null). Returns NaN if the null distribution has zero variance.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
observed
|
float
|
The observed score from the original (non-shuffled) data. |
required |
null_scores
|
NDArray[float64]
|
Array of scores from shuffled data, forming the null distribution. |
required |
Returns:
| Type | Description |
|---|---|
float
|
The z-score (standard score). NaN if null has zero variance or contains only one value. |
Notes
The z-score indicates how many standard deviations the observed value is from the mean of the null distribution. Positive values indicate the observed is above the null mean; negative values indicate below.
Common interpretation thresholds:
- |z| > 1.96: Approximately p < 0.05 (two-tailed) if null is normal
- |z| > 2.58: Approximately p < 0.01 (two-tailed) if null is normal
- |z| > 3.0: Strong evidence against null hypothesis
Unlike p-values, z-scores are useful for comparing effect sizes across different analyses.
Examples:
>>> null = np.array([1.0, 2.0, 3.0, 4.0, 5.0])
>>> observed = 5.0
>>> z = compute_shuffle_zscore(observed, null)
>>> bool(np.isclose(z, (5.0 - 3.0) / np.std(null)))
True
See Also
compute_shuffle_pvalue : Compute Monte Carlo p-value from null distribution. ShuffleTestResult : Container for shuffle test results.
Source code in src/neurospatial/stats/shuffle.py
1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 | |
shuffle_cell_identity
¶
shuffle_cell_identity(spike_counts: NDArray[int64], encoding_models: NDArray[float64], *, n_shuffles: int = 1000, rng: Generator | int | None = None) -> Generator[tuple[NDArray[np.int64], NDArray[np.float64]], None, None]
Shuffle mapping between spike trains and place fields.
Primary test for spatial code coherence. Disrupts the learned relationship between a neuron's activity and its encoded spatial location by randomly permuting which spike train is associated with which place field.
This shuffle randomly permutes columns (neuron axis) of spike_counts, effectively reassigning which place field goes with which spike train. The encoding models are returned unchanged.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
spike_counts
|
(NDArray[int64], shape(n_time_bins, n_neurons))
|
Spike counts per neuron per time bin. |
required |
encoding_models
|
(NDArray[float64], shape(n_neurons, n_bins))
|
Firing rate maps (place fields) for each neuron. |
required |
n_shuffles
|
int
|
Number of shuffled versions to generate. |
1000
|
rng
|
Generator | int | None
|
Random number generator for reproducibility.
|
None
|
Yields:
| Name | Type | Description |
|---|---|---|
shuffled_counts |
(NDArray[int64], shape(n_time_bins, n_neurons))
|
Spike counts with neuron identities permuted (columns shuffled). |
encoding_models |
(NDArray[float64], shape(n_neurons, n_bins))
|
Original encoding models (unchanged, same object). |
Raises:
| Type | Description |
|---|---|
ValueError
|
If the number of neurons in |
Notes
- Randomly permutes columns of spike_counts (neuron axis)
- Encoding models remain fixed (same object returned each iteration)
- Equivalent to randomly reassigning which place field goes with which spike train
- Preserves spike counts per neuron per time bin
- Preserves total spikes per time bin
- Caution: Can introduce noise if firing rates differ greatly between cells
Alternative implementation (equivalent): Instead of shuffling spike_counts columns, shuffle encoding_models rows. This yields the same decoded posteriors.
Examples:
>>> spike_counts = np.array([[0, 1, 2], [2, 0, 1]], dtype=np.int64)
>>> encoding_models = np.array([[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]])
>>> for i, (shuffled, models) in enumerate(
... shuffle_cell_identity(spike_counts, encoding_models, n_shuffles=3, rng=42)
... ):
... print(
... f"Shuffle {i}: sum={shuffled.sum()}, models unchanged={models is encoding_models}"
... )
Shuffle 0: sum=6, models unchanged=True
Shuffle 1: sum=6, models unchanged=True
Shuffle 2: sum=6, models unchanged=True
See Also
shuffle_place_fields_circular : Circular shift of place fields shuffle_time_bins : Temporal order shuffle
Source code in src/neurospatial/stats/shuffle.py
230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 | |
shuffle_place_fields_circular
¶
shuffle_place_fields_circular(encoding_models: NDArray[float64], *, n_shuffles: int = 1000, rng: Generator | int | None = None) -> Generator[NDArray[np.float64], None, None]
Circularly shift each place field by a random amount.
Conservative null hypothesis. Preserves individual cell spiking properties and local place field structure while disrupting spatial relationships between neurons.
Each neuron's place field is independently shifted by a random amount along the position axis. This preserves the shape of each place field but destroys the spatial relationships between neurons.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
encoding_models
|
(NDArray[float64], shape(n_neurons, n_bins))
|
Firing rate maps (place fields) for each neuron. |
required |
n_shuffles
|
int
|
Number of shuffled versions to generate. |
1000
|
rng
|
Generator | int | None
|
Random number generator for reproducibility.
|
None
|
Yields:
| Name | Type | Description |
|---|---|---|
shuffled_models |
(NDArray[float64], shape(n_neurons, n_bins))
|
Place fields with each row circularly shifted by a random amount. |
Notes
- Each neuron's place field is shifted independently
- Preserves the shape of each place field
- Destroys spatial relationships between neurons
- More conservative than cell identity shuffle
- For 2D environments: consider
shuffle_place_fields_circular_2d
Examples:
>>> encoding_models = np.array(
... [
... [1.0, 2.0, 3.0, 4.0],
... [4.0, 3.0, 2.0, 1.0],
... ]
... )
>>> for i, shuffled in enumerate(
... shuffle_place_fields_circular(encoding_models, n_shuffles=3, rng=42)
... ):
... print(f"Shuffle {i}: shape={shuffled.shape}")
Shuffle 0: shape=(2, 4)
Shuffle 1: shape=(2, 4)
Shuffle 2: shape=(2, 4)
See Also
shuffle_place_fields_circular_2d : 2D circular shift for 2D environments shuffle_cell_identity : Shuffle neuron-to-place-field mapping
Source code in src/neurospatial/stats/shuffle.py
335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 | |
shuffle_place_fields_circular_2d
¶
shuffle_place_fields_circular_2d(encoding_models: NDArray[float64], env: Environment, *, n_shuffles: int = 1000, rng: Generator | int | None = None) -> Generator[NDArray[np.float64], None, None]
Circularly shift 2D place fields in both dimensions.
For 2D environments, shifts place fields in both x and y dimensions.
This is the 2D analog of shuffle_place_fields_circular.
Each neuron's place field is reshaped to a 2D grid, shifted by random amounts in both dimensions, and flattened back to 1D.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
encoding_models
|
(NDArray[float64], shape(n_neurons, n_bins))
|
Firing rate maps (place fields) for each neuron. |
required |
env
|
Environment
|
2D environment with grid layout (provides |
required |
n_shuffles
|
int
|
Number of shuffled versions to generate. |
1000
|
rng
|
Generator | int | None
|
Random number generator for reproducibility.
|
None
|
Yields:
| Name | Type | Description |
|---|---|---|
shuffled_models |
(NDArray[float64], shape(n_neurons, n_bins))
|
Place fields with 2D circular shifts applied. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If environment is not 2D or doesn't have grid layout. |
Examples:
>>> import numpy as np
>>> from neurospatial import Environment
>>> from neurospatial.stats.shuffle import shuffle_place_fields_circular_2d
>>> positions = np.random.default_rng(42).uniform(0, 10, (100, 2))
>>> env = Environment.from_samples(positions, bin_size=2.0)
>>> encoding_models = np.random.default_rng(42).random(
... (3, env.n_bins)
... )
>>> for i, shuffled in enumerate(
... shuffle_place_fields_circular_2d(encoding_models, env, n_shuffles=3, rng=42)
... ):
... print(f"Shuffle {i}: shape={shuffled.shape}")
Shuffle 0: shape=(3, 36)
Shuffle 1: shape=(3, 36)
Shuffle 2: shape=(3, 36)
See Also
shuffle_place_fields_circular : 1D circular shift shuffle_cell_identity : Shuffle neuron-to-place-field mapping
Source code in src/neurospatial/stats/shuffle.py
420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 | |
shuffle_posterior_circular
¶
shuffle_posterior_circular(posterior: NDArray[float64], *, n_shuffles: int = 1000, rng: Generator | int | None = None) -> Generator[NDArray[np.float64], None, None]
Circularly shift posterior at each time bin independently.
Controls for chance linear alignment of position estimates by disrupting trajectory progression while preserving local smoothness. Each time bin's posterior is independently shifted by a random amount along the position axis.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
posterior
|
(NDArray[float64], shape(n_time_bins, n_bins))
|
Posterior probability distribution from decoding. Each row should sum to 1.0. |
required |
n_shuffles
|
int
|
Number of shuffled versions to generate. |
1000
|
rng
|
Generator | int | None
|
Random number generator for reproducibility.
|
None
|
Yields:
| Name | Type | Description |
|---|---|---|
shuffled_posterior |
(NDArray[float64], shape(n_time_bins, n_bins))
|
Posterior with each row circularly shifted by a random amount. |
Notes
- Each time bin is shifted independently
- Preserves the shape of each instantaneous posterior
- Destroys temporal continuity of decoded positions
- Caution: Can generate position representations that don't exist in original data (edge effects near track boundaries)
- Normalization is preserved (each row still sums to 1.0)
Examples:
>>> # Create a normalized posterior (3 time bins, 5 spatial bins)
>>> raw = np.array(
... [
... [0.1, 0.2, 0.4, 0.2, 0.1],
... [0.5, 0.3, 0.1, 0.05, 0.05],
... [0.05, 0.1, 0.2, 0.4, 0.25],
... ]
... )
>>> for i, shuffled in enumerate(
... shuffle_posterior_circular(raw, n_shuffles=3, rng=42)
... ):
... print(
... f"Shuffle {i}: shape={shuffled.shape}, sums to 1={np.allclose(shuffled.sum(axis=1), 1.0)}"
... )
Shuffle 0: shape=(3, 5), sums to 1=True
Shuffle 1: shape=(3, 5), sums to 1=True
Shuffle 2: shape=(3, 5), sums to 1=True
See Also
shuffle_posterior_weighted_circular : Weighted circular shift with edge effect mitigation shuffle_place_fields_circular : Circular shift of place fields
Source code in src/neurospatial/stats/shuffle.py
554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 | |
shuffle_posterior_weighted_circular
¶
shuffle_posterior_weighted_circular(posterior: NDArray[float64], *, edge_buffer: int = 5, n_shuffles: int = 1000, rng: Generator | int | None = None) -> Generator[NDArray[np.float64], None, None]
Weighted circular shift with edge effect mitigation.
Refined version of posterior shuffle that maintains non-uniformity and reduces edge effects by restricting shifts when the MAP position is near track boundaries. This is more conservative than standard circular shuffle.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
posterior
|
(NDArray[float64], shape(n_time_bins, n_bins))
|
Posterior probability distribution from decoding. Each row should sum to 1.0. |
required |
edge_buffer
|
int
|
Number of bins from track ends where shifts are restricted.
When the MAP position is within Default justification (5 bins): For typical 1D track decoding with 2-5 cm spatial bins, 5 bins = 10-25 cm buffer region. This prevents unrealistic position jumps (e.g., rat at start wrapping to end) while allowing sufficient shift variability. Recommended values:
For 2D environments or circular tracks, use |
5
|
n_shuffles
|
int
|
Number of shuffled versions to generate. |
1000
|
rng
|
Generator | int | None
|
Random number generator for reproducibility.
|
None
|
Yields:
| Name | Type | Description |
|---|---|---|
shuffled_posterior |
(NDArray[float64], shape(n_time_bins, n_bins))
|
Posterior with weighted circular shifts applied. |
Notes
- More conservative than standard circular shuffle
- Shifts are restricted near track ends to mitigate edge effects
- When MAP position is within
edge_bufferof an edge, shift is constrained to avoid wrapping probability mass to the other end - Preserves row normalization (each row still sums to 1.0)
The edge restriction works as follows:
- For each time bin, compute MAP position (argmax)
- If MAP is within edge_buffer of left edge (bin < edge_buffer):
restrict shift to [-MAP, n_bins - 2*edge_buffer] to keep MAP in center
- If MAP is within edge_buffer of right edge (bin >= n_bins - edge_buffer):
restrict shift to [-(MAP - center), n_bins - MAP] to keep MAP in center
- Otherwise: allow full circular shift [0, n_bins)
Example with n_bins=20 and edge_buffer=5: - MAP at bin 2 (near left): shift limited to [-2, 10) so MAP stays in [0, 11] - MAP at bin 17 (near right): shift limited to [-7, 3) so MAP stays in [10, 19] - MAP at bin 10 (center): full circular shift [0, 20)
Examples:
>>> import numpy as np
>>> from neurospatial.stats.shuffle import shuffle_posterior_weighted_circular
>>> # Create a normalized posterior (3 time bins, 10 spatial bins)
>>> raw = np.random.default_rng(42).random((3, 10))
>>> posterior = raw / raw.sum(axis=1, keepdims=True)
>>> for i, shuffled in enumerate(
... shuffle_posterior_weighted_circular(
... posterior, edge_buffer=2, n_shuffles=3, rng=42
... )
... ):
... print(f"Shuffle {i}: sums to 1={np.allclose(shuffled.sum(axis=1), 1.0)}")
Shuffle 0: sums to 1=True
Shuffle 1: sums to 1=True
Shuffle 2: sums to 1=True
See Also
shuffle_posterior_circular : Standard circular shift without edge restriction shuffle_place_fields_circular : Circular shift of place fields
Source code in src/neurospatial/stats/shuffle.py
643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 | |
shuffle_spikes_isi
¶
shuffle_spikes_isi(spike_times: NDArray[float64], *, n_shuffles: int = 1000, rng: Generator | int | None = None) -> Generator[NDArray[np.float64], None, None]
Shuffle inter-spike intervals to test ISI ordering significance.
Creates surrogate spike trains by randomly permuting the inter-spike intervals (ISIs). The first spike time is preserved, and ISIs are shuffled and cumulatively summed to create new spike times. This preserves the ISI distribution while destroying temporal correlations.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
spike_times
|
(NDArray[float64], shape(n_spikes))
|
Sorted array of spike times for a single neuron. |
required |
n_shuffles
|
int
|
Number of shuffled versions to generate. |
1000
|
rng
|
Generator | int | None
|
Random number generator for reproducibility.
|
None
|
Yields:
| Name | Type | Description |
|---|---|---|
shuffled_times |
(NDArray[float64], shape(n_spikes))
|
Spike times with shuffled ISIs (same first spike time). |
Notes
- Preserves the first spike time exactly
- Preserves the ISI distribution (same ISI values, different order)
- Destroys temporal correlations between consecutive ISIs
- Output spike times are always sorted (monotonically increasing)
- Useful for testing ISI-dependent effects (e.g., burst detection, adaptation)
Examples:
>>> spike_times = np.array([0.1, 0.15, 0.25, 0.4, 0.45, 0.7])
>>> for i, shuffled in enumerate(
... shuffle_spikes_isi(spike_times, n_shuffles=3, rng=42)
... ):
... print(
... f"Shuffle {i}: first spike preserved = {shuffled[0] == spike_times[0]}"
... )
Shuffle 0: first spike preserved = True
Shuffle 1: first spike preserved = True
Shuffle 2: first spike preserved = True
See Also
shuffle_time_bins : Shuffle temporal order of binned spike counts. generate_poisson_surrogates : Generate Poisson surrogate spike trains.
Source code in src/neurospatial/stats/shuffle.py
1217 1218 1219 1220 1221 1222 1223 1224 1225 1226 1227 1228 1229 1230 1231 1232 1233 1234 1235 1236 1237 1238 1239 1240 1241 1242 1243 1244 1245 1246 1247 1248 1249 1250 1251 1252 1253 1254 1255 1256 1257 1258 1259 1260 1261 1262 1263 1264 1265 1266 1267 1268 1269 1270 1271 1272 1273 1274 1275 1276 1277 1278 1279 1280 1281 1282 1283 1284 1285 1286 1287 1288 1289 1290 1291 1292 1293 1294 1295 1296 1297 | |
shuffle_time_bins
¶
shuffle_time_bins(spike_counts: NDArray[int64], *, n_shuffles: int = 1000, rng: Generator | int | None = None) -> Generator[NDArray[np.int64], None, None]
Shuffle temporal order of time bins within an event.
Primary test for sequential structure. Disrupts temporal order while preserving instantaneous firing characteristics.
This shuffle randomly permutes the rows (time bins) of the spike count matrix. Each row is kept intact, so the spike counts per neuron per time bin are preserved, but the temporal sequence is destroyed.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
spike_counts
|
(NDArray[int64], shape(n_time_bins, n_neurons))
|
Spike counts for a single candidate event (PBE/SWR). |
required |
n_shuffles
|
int
|
Number of shuffled versions to generate. |
1000
|
rng
|
Generator | int | None
|
Random number generator for reproducibility.
|
None
|
Yields:
| Name | Type | Description |
|---|---|---|
shuffled_counts |
(NDArray[int64], shape(n_time_bins, n_neurons))
|
Spike counts with time bins in random order. |
Notes
- Preserves spike counts per neuron per time bin
- Preserves total spikes per neuron across event
- Destroys temporal sequence information
- Underpowered with very few time bins (<5)
Examples:
>>> spike_counts = np.array([[0, 1], [2, 0], [1, 1]], dtype=np.int64)
>>> for i, shuffled in enumerate(
... shuffle_time_bins(spike_counts, n_shuffles=3, rng=42)
... ):
... print(f"Shuffle {i}: shape={shuffled.shape}, sum={shuffled.sum()}")
Shuffle 0: shape=(3, 2), sum=5
Shuffle 1: shape=(3, 2), sum=5
Shuffle 2: shape=(3, 2), sum=5
See Also
shuffle_time_bins_coherent : Coherent shuffle (same permutation for all neurons)
Source code in src/neurospatial/stats/shuffle.py
shuffle_time_bins_coherent
¶
shuffle_time_bins_coherent(spike_counts: NDArray[int64], *, n_shuffles: int = 1000, rng: Generator | int | None = None) -> Generator[NDArray[np.int64], None, None]
Shuffle time bins coherently across all neurons (time-swap shuffle).
Preserves population co-activation structure while disrupting temporal
order. This is the same as shuffle_time_bins in implementation, but
is named differently to emphasize that the permutation is coherent
across all neurons (preserving instantaneous population vectors).
This shuffle is less conservative than independent per-neuron shuffles because pairwise correlations between neurons are maintained within each time bin.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
spike_counts
|
(NDArray[int64], shape(n_time_bins, n_neurons))
|
Spike counts for a single candidate event. |
required |
n_shuffles
|
int
|
Number of shuffled versions to generate. |
1000
|
rng
|
Generator | int | None
|
Random number generator for reproducibility.
|
None
|
Yields:
| Name | Type | Description |
|---|---|---|
shuffled_counts |
(NDArray[int64], shape(n_time_bins, n_neurons))
|
Spike counts with rows (time bins) permuted coherently. |
Notes
- All neurons see the same temporal permutation
- Preserves instantaneous population vectors
- Destroys temporal progression but keeps co-firing structure
- Mathematically equivalent to
shuffle_time_bins, but the function name clarifies the intent (testing temporal structure while preserving population co-activation)
Examples:
>>> spike_counts = np.array([[0, 1, 2], [2, 0, 1], [1, 2, 0]], dtype=np.int64)
>>> for shuffled in shuffle_time_bins_coherent(spike_counts, n_shuffles=2, rng=42):
... # Each row is preserved exactly (coherent permutation)
... original_rows = {tuple(row) for row in spike_counts}
... shuffled_rows = {tuple(row) for row in shuffled}
... print(f"Row sets match: {original_rows == shuffled_rows}")
Row sets match: True
Row sets match: True
See Also
shuffle_time_bins : Primary temporal shuffle (same implementation) shuffle_cell_identity : Shuffle neuron-to-place-field mapping
Source code in src/neurospatial/stats/shuffle.py
shuffle_trials
¶
shuffle_trials(trial_labels: NDArray[int64], *, n_shuffles: int = 1000, rng: Generator | int | None = None) -> Generator[NDArray[np.int64], None, None]
Shuffle trial labels to test trial identity significance.
Randomly permutes trial labels to create a null distribution for testing whether observed effects depend on trial identity. Useful for testing learning effects, trial-type differences, or temporal dependencies.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
trial_labels
|
(NDArray[int64], shape(n_samples))
|
Array of trial labels/indices for each sample (e.g., timepoint). Labels can be any integers (e.g., 0, 1, 2 or trial IDs). |
required |
n_shuffles
|
int
|
Number of shuffled versions to generate. |
1000
|
rng
|
Generator | int | None
|
Random number generator for reproducibility.
|
None
|
Yields:
| Name | Type | Description |
|---|---|---|
shuffled_labels |
(NDArray[int64], shape(n_samples))
|
Trial labels with permuted order. |
Notes
- Permutes the entire label array (all samples)
- Preserves the distribution of labels (same counts per label)
- Destroys the temporal association between samples and trials
- Useful for testing trial-type effects or learning-related changes
Examples:
>>> trial_labels = np.array([0, 0, 0, 1, 1, 1, 2, 2, 2])
>>> for i, shuffled in enumerate(
... shuffle_trials(trial_labels, n_shuffles=3, rng=42)
... ):
... print(
... f"Shuffle {i}: unique values preserved = {set(shuffled) == set(trial_labels)}"
... )
Shuffle 0: unique values preserved = True
Shuffle 1: unique values preserved = True
Shuffle 2: unique values preserved = True
See Also
shuffle_time_bins : Shuffle temporal order of spike counts. shuffle_cell_identity : Shuffle neuron-to-place-field mapping.
Source code in src/neurospatial/stats/shuffle.py
1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172 1173 1174 1175 1176 1177 1178 1179 1180 1181 1182 1183 1184 1185 1186 1187 1188 1189 1190 1191 1192 1193 1194 1195 1196 1197 1198 1199 1200 1201 1202 1203 1204 1205 1206 1207 1208 1209 1210 1211 1212 1213 1214 | |
generate_inhomogeneous_poisson_surrogates
¶
generate_inhomogeneous_poisson_surrogates(spike_counts: NDArray[int64], *, smoothing_window: int = 3, n_surrogates: int = 1000, rng: Generator | int | None = None) -> Generator[NDArray[np.int64], None, None]
Generate inhomogeneous Poisson surrogate spike trains with smoothed rates.
Creates surrogate spike counts by sampling from Poisson distributions with time-varying rates estimated from smoothed spike counts. This preserves slow rate fluctuations while destroying fine temporal structure.
Tests that structure exceeds time-varying rate expectations. More conservative than homogeneous surrogates - controls for rate modulation while testing for sequence-specific coding.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
spike_counts
|
(NDArray[int64], shape(n_time_bins, n_neurons))
|
Original spike counts per neuron per time bin. |
required |
smoothing_window
|
int
|
Size of the uniform smoothing window (in time bins) applied to estimate time-varying rates. Larger values preserve less temporal detail (closer to homogeneous). Must be at least 1. |
3
|
n_surrogates
|
int
|
Number of surrogate spike trains to generate. |
1000
|
rng
|
Generator | int | None
|
Random number generator for reproducibility.
|
None
|
Yields:
| Name | Type | Description |
|---|---|---|
surrogate |
(NDArray[int64], shape(n_time_bins, n_neurons))
|
Surrogate spike counts sampled from time-varying Poisson distributions. |
Notes
- Uses uniform 1D filter to smooth spike counts in time
- Smoothed counts are used as time-varying Poisson rate parameters
- Preserves slow rate fluctuations (e.g., event onsets)
- Destroys fine temporal correlations and millisecond-scale patterns
- More appropriate than homogeneous surrogates when rate modulation is a confound (e.g., ramping activity during replay events)
Examples:
>>> import numpy as np
>>> from neurospatial.stats.surrogates import (
... generate_inhomogeneous_poisson_surrogates,
... )
>>> spike_counts = np.array(
... [[0, 1], [2, 2], [3, 3], [2, 2], [1, 1]], dtype=np.int64
... )
>>> for i, surrogate in enumerate(
... generate_inhomogeneous_poisson_surrogates(
... spike_counts, smoothing_window=3, n_surrogates=3, rng=42
... )
... ):
... print(f"Surrogate {i}: shape={surrogate.shape}")
Surrogate 0: shape=(5, 2)
Surrogate 1: shape=(5, 2)
Surrogate 2: shape=(5, 2)
See Also
generate_poisson_surrogates : Homogeneous Poisson surrogates generate_jittered_spikes : Temporal jitter surrogates
Source code in src/neurospatial/stats/surrogates.py
139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 | |
generate_jittered_spikes
¶
generate_jittered_spikes(spike_times: NDArray[float64], jitter_std: float, *, n_surrogates: int = 1000, rng: Generator | int | None = None, window: tuple[float, float] | None = None) -> Generator[NDArray[np.float64], None, None]
Generate temporally jittered surrogate spike trains.
Creates surrogate spike trains by adding Gaussian noise to each spike time. This tests whether precise spike timing is significant while preserving the approximate temporal distribution of spikes.
Tests that precise spike timing matters. If observed effects depend on exact spike times, jittered surrogates should produce weaker effects.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
spike_times
|
(NDArray[float64], shape(n_spikes))
|
Sorted array of spike times for a single neuron. |
required |
jitter_std
|
float
|
Standard deviation of the Gaussian jitter added to each spike time. Units should match spike_times (typically seconds). |
required |
n_surrogates
|
int
|
Number of surrogate spike trains to generate. |
1000
|
rng
|
Generator | int | None
|
Random number generator for reproducibility.
|
None
|
window
|
tuple[float, float] | None
|
If provided, clip spike times to stay within (min_time, max_time). Useful to prevent spikes from jittering outside the recording period. |
None
|
Yields:
| Name | Type | Description |
|---|---|---|
jittered_times |
(NDArray[float64], shape(n_spikes))
|
Spike times with Gaussian jitter applied (sorted). |
Notes
- Each spike is independently jittered by adding Gaussian noise
- Output spike times are always sorted (monotonically increasing)
- Preserves the number of spikes exactly
- Preserves approximate temporal distribution at scales > jitter_std
- Destroys precise timing relationships at scales < jitter_std
- Useful for testing phase locking, synchrony, or sequence timing
Examples:
>>> spike_times = np.array([0.1, 0.15, 0.25, 0.4, 0.45, 0.7])
>>> for i, jittered in enumerate(
... generate_jittered_spikes(
... spike_times, jitter_std=0.01, n_surrogates=3, rng=42
... )
... ):
... print(f"Surrogate {i}: n_spikes={len(jittered)}")
Surrogate 0: n_spikes=6
Surrogate 1: n_spikes=6
Surrogate 2: n_spikes=6
See Also
generate_poisson_surrogates : Rate-matched surrogates (destroys all timing) neurospatial.stats.shuffle.shuffle_spikes_isi : ISI-preserving shuffle
Source code in src/neurospatial/stats/surrogates.py
242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 | |
generate_poisson_surrogates
¶
generate_poisson_surrogates(spike_counts: NDArray[int64], *, n_surrogates: int = 1000, rng: Generator | int | None = None) -> Generator[NDArray[np.int64], None, None]
Generate homogeneous Poisson surrogate spike trains.
Creates surrogate spike counts by sampling from Poisson distributions with rates equal to the mean firing rate of each neuron across all time bins. This destroys all temporal structure while preserving average rates.
Tests that observed structure exceeds rate-based expectations. If sequential decoding patterns are significant, they should score higher than patterns from rate-matched surrogates.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
spike_counts
|
(NDArray[int64], shape(n_time_bins, n_neurons))
|
Original spike counts per neuron per time bin. |
required |
n_surrogates
|
int
|
Number of surrogate spike trains to generate. |
1000
|
rng
|
Generator | int | None
|
Random number generator for reproducibility.
|
None
|
Yields:
| Name | Type | Description |
|---|---|---|
surrogate |
(NDArray[int64], shape(n_time_bins, n_neurons))
|
Surrogate spike counts sampled from Poisson distributions. |
Notes
- Computes mean spike count per neuron across all time bins
- Each time bin independently samples from Poisson(mean_count_per_neuron)
- Destroys all temporal correlations and patterns
- Preserves mean firing rates per neuron (statistically)
- More conservative than shuffle methods (generates truly independent counts)
Examples:
>>> spike_counts = np.array(
... [[0, 1], [2, 0], [1, 1]], dtype=np.int64
... )
>>> for i, surrogate in enumerate(
... generate_poisson_surrogates(spike_counts, n_surrogates=3, rng=42)
... ):
... print(f"Surrogate {i}: shape={surrogate.shape}, total={surrogate.sum()}")
Surrogate 0: shape=(3, 2), total=3
Surrogate 1: shape=(3, 2), total=5
Surrogate 2: shape=(3, 2), total=6
See Also
generate_inhomogeneous_poisson_surrogates : Time-varying rate surrogates generate_jittered_spikes : Temporal jitter surrogates