commsubspace 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025-2026 Eliezyer de Oliveira
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,68 @@
1
+ Metadata-Version: 2.4
2
+ Name: commsubspace
3
+ Version: 0.1.0
4
+ Summary: Communication subspaces between neural populations: CCA, reduced-rank regression and contrastive communication subspaces (cCS).
5
+ Author-email: Eliezyer de Oliveira <eliezyer.deoliveira@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/eliezyer/commsubspace
8
+ Project-URL: Issues, https://github.com/eliezyer/commsubspace/issues
9
+ Keywords: neuroscience,communication subspace,CCA,reduced rank regression,contrastive
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Science/Research
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Scientific/Engineering
14
+ Requires-Python: >=3.10
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Requires-Dist: numpy
18
+ Requires-Dist: scipy
19
+ Requires-Dist: scikit-learn
20
+ Dynamic: license-file
21
+
22
+ # contrastive communication subspace
23
+
24
+ Python code for **contrastive communication subspace** and also implementations of regular communication subspace (CS) analysis between two neural populations (region 1 and region 2):
25
+
26
+ - `ReducedRankRegress` - reduced-rank regression (RRR) from region 1 to region 2. The original method used for CS in Semedo et al., 2019
27
+ - `CrossDecomposition` - CCA (`method='correlation'`) and PLS-SVD (`method='covariance'`). Other ways of achieving CS
28
+ - `cCS` - **contrastive communication subspace**: finds pairs of directions whose coupling between the two regions differs most between two conditions (A and B). Objectives: `'difference'`, `'ratio'`, `'normalized_difference'` and `'normalized_index'`.
29
+
30
+ All data arrays are `(n_samples, n_features)`.
31
+
32
+ ## Installation
33
+
34
+ ```bash
35
+ pip install commsubspace
36
+ ```
37
+
38
+ Requires Python >= 3.10, NumPy, SciPy and scikit-learn.
39
+
40
+ ## Quick start
41
+
42
+ ```python
43
+ import numpy as np
44
+ from commsubspace import cCS, CrossDecomposition, ReducedRankRegress
45
+
46
+ rng = np.random.default_rng(0)
47
+ region1_A, region2_A = rng.standard_normal((500, 20)), rng.standard_normal((500, 15)) # condition A
48
+ region1_B, region2_B = rng.standard_normal((400, 20)), rng.standard_normal((400, 15)) # condition B
49
+
50
+ # Contrastive communication subspace: coupling that differs between conditions A and B.
51
+ ccs = cCS(method="normalized_index").fit(region1_A, region2_A, region1_B, region2_B)
52
+ print(ccs.region1_loadings_.shape, ccs.contrast_values_.shape) # (20, N_dim), (N_dim,)
53
+
54
+ # Canonical correlation analysis between the two regions (condition A only).
55
+ cca = CrossDecomposition(method="correlation").fit(region1_A, region2_A)
56
+ scores_region1, scores_region2 = cca.transform(region1_A, region2_A)
57
+
58
+ # Reduced-rank regression of region 2 on region 1, with 3 predictive dimensions.
59
+ rrr = ReducedRankRegress(rank=3).fit(region1_A, region2_A)
60
+ predicted_region2 = rrr.predict(region1_A)
61
+ ```
62
+
63
+ See the docstrings of `cCS`, `CrossDecomposition` and `ReducedRankRegress` for all options, fitted attributes and
64
+ units. Fitted `cCS` objects carry a `meta_` dictionary with the parameters, random seed and analysis version.
65
+
66
+ ## License
67
+
68
+ MIT, see `LICENSE`.
@@ -0,0 +1,47 @@
1
+ # contrastive communication subspace
2
+
3
+ Python code for **contrastive communication subspace** and also implementations of regular communication subspace (CS) analysis between two neural populations (region 1 and region 2):
4
+
5
+ - `ReducedRankRegress` - reduced-rank regression (RRR) from region 1 to region 2. The original method used for CS in Semedo et al., 2019
6
+ - `CrossDecomposition` - CCA (`method='correlation'`) and PLS-SVD (`method='covariance'`). Other ways of achieving CS
7
+ - `cCS` - **contrastive communication subspace**: finds pairs of directions whose coupling between the two regions differs most between two conditions (A and B). Objectives: `'difference'`, `'ratio'`, `'normalized_difference'` and `'normalized_index'`.
8
+
9
+ All data arrays are `(n_samples, n_features)`.
10
+
11
+ ## Installation
12
+
13
+ ```bash
14
+ pip install commsubspace
15
+ ```
16
+
17
+ Requires Python >= 3.10, NumPy, SciPy and scikit-learn.
18
+
19
+ ## Quick start
20
+
21
+ ```python
22
+ import numpy as np
23
+ from commsubspace import cCS, CrossDecomposition, ReducedRankRegress
24
+
25
+ rng = np.random.default_rng(0)
26
+ region1_A, region2_A = rng.standard_normal((500, 20)), rng.standard_normal((500, 15)) # condition A
27
+ region1_B, region2_B = rng.standard_normal((400, 20)), rng.standard_normal((400, 15)) # condition B
28
+
29
+ # Contrastive communication subspace: coupling that differs between conditions A and B.
30
+ ccs = cCS(method="normalized_index").fit(region1_A, region2_A, region1_B, region2_B)
31
+ print(ccs.region1_loadings_.shape, ccs.contrast_values_.shape) # (20, N_dim), (N_dim,)
32
+
33
+ # Canonical correlation analysis between the two regions (condition A only).
34
+ cca = CrossDecomposition(method="correlation").fit(region1_A, region2_A)
35
+ scores_region1, scores_region2 = cca.transform(region1_A, region2_A)
36
+
37
+ # Reduced-rank regression of region 2 on region 1, with 3 predictive dimensions.
38
+ rrr = ReducedRankRegress(rank=3).fit(region1_A, region2_A)
39
+ predicted_region2 = rrr.predict(region1_A)
40
+ ```
41
+
42
+ See the docstrings of `cCS`, `CrossDecomposition` and `ReducedRankRegress` for all options, fitted attributes and
43
+ units. Fitted `cCS` objects carry a `meta_` dictionary with the parameters, random seed and analysis version.
44
+
45
+ ## License
46
+
47
+ MIT, see `LICENSE`.
@@ -0,0 +1,10 @@
1
+ # commsubspace/__init__.py
2
+ # Public API of the commsubspace package (communication subspaces between two neural populations).
3
+
4
+ from .communication_subspace import CrossDecomposition, ReducedRankRegress
5
+ from .contrastive_communication_subspace import cCS
6
+
7
+ # Keep in sync with ``version`` in pyproject.toml (checked by tests/test_packaging.py).
8
+ __version__ = "0.1.0"
9
+
10
+ __all__ = ["cCS", "CrossDecomposition", "ReducedRankRegress", "__version__"]
@@ -0,0 +1,64 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Small linear-algebra helpers shared by the contrastive communication subspace."""
3
+
4
+ import numpy as np
5
+
6
+
7
+ def psd_inverse_sqrt_factor(matrix, relative_tolerance):
8
+ """Whitening factor F of a PSD matrix on its numerical range: F^T M F = I.
9
+
10
+ With M = Q diag(lambda) Q^T, F = Q_r diag(lambda_r)^(-1/2) where only the
11
+ eigenvalues above ``relative_tolerance * max(lambda)`` are kept (numerical rank,
12
+ the same condition-number rule as ``numpy.linalg.matrix_rank``). Directions in
13
+ which M is numerically zero are left out, so no ridge is needed.
14
+
15
+ Parameters
16
+ ----------
17
+ matrix : ndarray, shape (m, m)
18
+ Symmetric positive semi-definite matrix (units: those of the matrix).
19
+ relative_tolerance : float
20
+ Relative eigenvalue cutoff in (0, 1).
21
+
22
+ Returns
23
+ -------
24
+ ndarray, shape (m, r)
25
+ Factor with r = numerical rank of ``matrix``; columns are Q_r / sqrt(lambda_r).
26
+ Units: 1 / sqrt(units of ``matrix``).
27
+ """
28
+ eigenvalues, eigenvectors = np.linalg.eigh(matrix)
29
+ kept = eigenvalues > relative_tolerance * eigenvalues.max()
30
+ return eigenvectors[:, kept] / np.sqrt(eigenvalues[kept])
31
+
32
+
33
+ def floored_polar_roots(matrix, floor):
34
+ """Square roots sqrt(M M^T) and sqrt(M^T M) of a rectangular matrix via its SVD.
35
+
36
+ Taking the SVD of ``M`` directly (instead of eigen-decomposing M M^T) keeps small
37
+ singular values accurate: forming M M^T squares the round-off error, so a true
38
+ singular value of 1e-12 would be reported as about 1e-8.
39
+
40
+ Parameters
41
+ ----------
42
+ matrix : ndarray, shape (m, n)
43
+ Real matrix (e.g. a cross-covariance between region 1 and region 2).
44
+ floor : float
45
+ Lower bound applied to every eigenvalue of both roots, including those of
46
+ directions outside the range of ``matrix`` (whose singular value is 0). Same
47
+ units as the singular values of ``matrix``. ``0`` gives the plain roots.
48
+
49
+ Returns
50
+ -------
51
+ left_root : ndarray, shape (m, m)
52
+ U diag(max(s, floor)) U^T, equal to sqrt(M M^T) when floor = 0.
53
+ right_root : ndarray, shape (n, n)
54
+ V diag(max(s, floor)) V^T, equal to sqrt(M^T M) when floor = 0.
55
+ """
56
+ left_vectors, singular_values, right_vectors_t = np.linalg.svd(matrix, full_matrices=True)
57
+ n_rows, n_cols = matrix.shape
58
+ left_values = np.full(n_rows, 0.0)
59
+ left_values[:singular_values.size] = singular_values
60
+ right_values = np.full(n_cols, 0.0)
61
+ right_values[:singular_values.size] = singular_values
62
+ left_root = (left_vectors * np.maximum(left_values, floor)) @ left_vectors.T
63
+ right_root = (right_vectors_t.T * np.maximum(right_values, floor)) @ right_vectors_t
64
+ return left_root, right_root
@@ -0,0 +1,107 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Noise floors for cross-covariance matrices.
3
+
4
+ A "noise floor" is the size (largest singular value) that a cross-covariance
5
+ matrix of two *unrelated* populations would reach just from finite-sample
6
+ estimation error. Singular values below it cannot be told apart from noise.
7
+
8
+ Two estimates are provided:
9
+
10
+ * analytic: random-matrix edge (sqrt(p) + sqrt(q)) for a p x q matrix of
11
+ independent unit-variance entries (Davidson & Szarek 2001; Vershynin 2018,
12
+ High-Dimensional Probability, Sec. 4.4; asymptotics: Bai & Yin 1993).
13
+ Assumes independent samples and white signals (see ``effective_n``).
14
+ * shuffle: top singular value of the cross-covariance after circularly shifting
15
+ one region in time; preserves autocorrelation of each region and destroys
16
+ their coupling.
17
+
18
+ All cross-covariances here are "sum type", C = R1^T R2 (no division by the number
19
+ of samples), the same convention used by ``cCS``.
20
+ """
21
+
22
+ import numpy as np
23
+
24
+
25
+ def analytic_noise_floor(n_samples, n_features_1, n_features_2, std_1, std_2,
26
+ effective_n=None):
27
+ """Analytic noise floor of the sum-type cross-covariance R1^T R2.
28
+
29
+ For independent, white signals the entries of R1^T R2 have standard
30
+ deviation sqrt(n_samples) * std_1 * std_2, and the largest singular value of
31
+ such a matrix is about (sqrt(p) + sqrt(q)) times that. With autocorrelated
32
+ samples, the effective number of independent samples ``effective_n`` replaces
33
+ ``n_samples`` inside the square root of the estimation error:
34
+ floor = n_samples * std_1 * std_2 * (sqrt(p) + sqrt(q)) / sqrt(effective_n).
35
+
36
+ Parameters
37
+ ----------
38
+ n_samples : int
39
+ Number of samples T of the condition.
40
+ n_features_1, n_features_2 : int
41
+ Number of features p (region 1) and q (region 2).
42
+ std_1, std_2 : float
43
+ Root-mean-square column standard deviation of region 1 and region 2
44
+ (same units as the data). For unit-norm columns this is 1/sqrt(T).
45
+ effective_n : float or None
46
+ Effective number of independent samples. ``None`` means ``n_samples``.
47
+
48
+ Returns
49
+ -------
50
+ float
51
+ Noise floor in the units of R1^T R2 (data units squared, or
52
+ correlation units when columns have unit norm).
53
+ """
54
+ if effective_n is None:
55
+ effective_n = n_samples
56
+ edge = np.sqrt(n_features_1) + np.sqrt(n_features_2)
57
+ return float(n_samples * std_1 * std_2 * edge / np.sqrt(effective_n))
58
+
59
+
60
+ def random_circular_shift(data, rng, min_fraction=0.1):
61
+ """Circularly shift ``data`` along time by a random lag.
62
+
63
+ Parameters
64
+ ----------
65
+ data : ndarray, shape (n_samples, n_features)
66
+ rng : numpy.random.Generator
67
+ min_fraction : float
68
+ The shift is drawn uniformly from [min_fraction * n_samples,
69
+ (1 - min_fraction) * n_samples] so the shifted copy is never nearly
70
+ aligned with the original.
71
+
72
+ Returns
73
+ -------
74
+ ndarray, shape (n_samples, n_features)
75
+ Row-rolled copy of ``data`` (same units).
76
+ """
77
+ n_samples = data.shape[0]
78
+ lowest = max(1, int(min_fraction * n_samples))
79
+ highest = max(lowest + 1, n_samples - lowest + 1)
80
+ return np.roll(data, int(rng.integers(lowest, highest)), axis=0)
81
+
82
+
83
+ def shuffle_noise_floor(region1, region2, n_shuffles, rng):
84
+ """Noise floor from circularly shifting region 2 relative to region 1.
85
+
86
+ Parameters
87
+ ----------
88
+ region1 : ndarray, shape (n_samples, p)
89
+ region2 : ndarray, shape (n_samples, q)
90
+ Same rows as ``region1`` (simultaneous samples), same units as used for
91
+ the cross-covariance being floored.
92
+ n_shuffles : int
93
+ Number of random circular shifts.
94
+ rng : numpy.random.Generator
95
+ Source of the random shifts (seed it for reproducibility).
96
+
97
+ Returns
98
+ -------
99
+ float
100
+ Mean over shuffles of the largest singular value of
101
+ region1^T shift(region2), in the units of region1^T region2.
102
+ """
103
+ top_singular_values = np.empty(n_shuffles)
104
+ for index in range(n_shuffles):
105
+ shifted = random_circular_shift(region2, rng)
106
+ top_singular_values[index] = np.linalg.svd(region1.T @ shifted, compute_uv=False)[0]
107
+ return float(top_singular_values.mean())