dependence-maps 0.1.1__py3-none-any.whl
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.
- dependence_maps-0.1.1.dist-info/METADATA +183 -0
- dependence_maps-0.1.1.dist-info/RECORD +10 -0
- dependence_maps-0.1.1.dist-info/WHEEL +5 -0
- dependence_maps-0.1.1.dist-info/licenses/LICENSE +21 -0
- dependence_maps-0.1.1.dist-info/top_level.txt +1 -0
- depmaps/__init__.py +21 -0
- depmaps/core.py +381 -0
- depmaps/inference.py +165 -0
- depmaps/plotting.py +162 -0
- depmaps/simulate.py +34 -0
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dependence-maps
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Dependence maps: signed, standardized deviations of the empirical copula from independence
|
|
5
|
+
Author: Ivan Medovikov
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Brainologist/dependence-maps
|
|
8
|
+
Project-URL: Source, https://github.com/Brainologist/dependence-maps
|
|
9
|
+
Project-URL: Issues, https://github.com/Brainologist/dependence-maps/issues
|
|
10
|
+
Keywords: copula,dependence,empirical copula,independence test,nonparametric statistics,visualization,tail dependence
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Science/Research
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Scientific/Engineering :: Mathematics
|
|
16
|
+
Classifier: Topic :: Scientific/Engineering :: Visualization
|
|
17
|
+
Requires-Python: >=3.9
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: numpy>=1.22
|
|
21
|
+
Requires-Dist: scipy>=1.8
|
|
22
|
+
Requires-Dist: matplotlib>=3.5
|
|
23
|
+
Provides-Extra: pandas
|
|
24
|
+
Requires-Dist: pandas>=1.4; extra == "pandas"
|
|
25
|
+
Provides-Extra: test
|
|
26
|
+
Requires-Dist: pytest>=7; extra == "test"
|
|
27
|
+
Requires-Dist: pandas>=1.4; extra == "test"
|
|
28
|
+
Dynamic: license-file
|
|
29
|
+
|
|
30
|
+
# dependence-maps
|
|
31
|
+
|
|
32
|
+
Dependence maps: a graphical tool for visualizing dependence between two scalar random variables.
|
|
33
|
+
|
|
34
|
+
A dependence map shows, on a grid of quantile levels (u, v), the signed and standardized deviation of the empirical copula from the independence copula. It indicates where in the joint distribution two variables depart from independence: around the median, in the joint upper or lower tail, or in the off-diagonal corners. The method was presented at CFE-CMStatistics 2024 ([slides](https://github.com/Brainologist/dependence-maps/blob/main/docs/CFE2024_dependence_maps_slides.pdf)).
|
|
35
|
+
|
|
36
|
+

|
|
37
|
+
|
|
38
|
+
## Installation
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install dependence-maps
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The package is imported as `depmaps` (the name `depmap` was already taken on PyPI). It requires Python 3.9 or later, NumPy, SciPy and Matplotlib. pandas is optional and is only needed for `to_frame()` and the `data=` argument:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pip install "dependence-maps[pandas]"
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
For the development version, clone [the repository](https://github.com/Brainologist/dependence-maps) and run `pip install -e ".[test]"`, then `pytest`.
|
|
51
|
+
|
|
52
|
+
## Quickstart
|
|
53
|
+
|
|
54
|
+
The data below follow a Student t copula with zero correlation and three degrees of freedom. The correlation coefficient is close to zero, but the two variables are far from independent.
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
import matplotlib.pyplot as plt
|
|
58
|
+
import depmaps as dpm
|
|
59
|
+
from depmaps.simulate import t_copula
|
|
60
|
+
|
|
61
|
+
uv = t_copula(5000, rho=0.0, df=3, rng=1)
|
|
62
|
+
x, y = uv[:, 0], uv[:, 1]
|
|
63
|
+
|
|
64
|
+
dm = dpm.dependence_map(x, y, grid=19) # quantile levels 0.05, ..., 0.95
|
|
65
|
+
dm.simultaneous_band(alpha=0.05) # critical value for max |Z| over the grid
|
|
66
|
+
print(dm.global_pvalue) # p-value of the test of independence
|
|
67
|
+
|
|
68
|
+
dm.plot() # dots mark cells that are not significant
|
|
69
|
+
plt.show()
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+

|
|
73
|
+
|
|
74
|
+
`x` and `y` can be NumPy arrays, lists or pandas Series of equal length. Pairs with a missing value are dropped. Columns of a DataFrame can be passed by name, as in `dpm.dependence_map("load", "price", data=df)`.
|
|
75
|
+
|
|
76
|
+
## Examples from the CFE 2024 talk
|
|
77
|
+
|
|
78
|
+
[`examples/cfe2024_examples.py`](https://github.com/Brainologist/dependence-maps/blob/main/examples/cfe2024_examples.py) reproduces the examples of the talk with simulated data and writes the figures below. Slide numbers refer to the [slides](https://github.com/Brainologist/dependence-maps/blob/main/docs/CFE2024_dependence_maps_slides.pdf).
|
|
79
|
+
|
|
80
|
+
### Linear dependence (Example 1, slides 3 and 19–20)
|
|
81
|
+
|
|
82
|
+
Gaussian copula with ρ = 0.8. The raw deviation Cₙ(u, v) − uv (middle panel) is largest at the median, but only because it must vanish at the edges of the unit square. The standardized map (right) shows dependence of similar strength along the whole diagonal.
|
|
83
|
+
|
|
84
|
+

|
|
85
|
+
|
|
86
|
+
### Tail dependence without correlation (Example 2, slides 4 and 21–23)
|
|
87
|
+
|
|
88
|
+
Student t copula with ρ = 0 and three degrees of freedom. Joint extremes of the same sign occur more often than under independence (red corners) and extremes of opposite sign less often (blue corners). The centre of the map shows nothing.
|
|
89
|
+
|
|
90
|
+

|
|
91
|
+
|
|
92
|
+
### Independence (slides 16–18)
|
|
93
|
+
|
|
94
|
+
Even under independence the map shows streaks, since neighbouring cells are strongly correlated. Significance is therefore judged against a simultaneous critical value, about 3.5 for a 19×19 grid, rather than the pointwise 1.96.
|
|
95
|
+
|
|
96
|
+

|
|
97
|
+
|
|
98
|
+
### U-shaped dependence in a time series (compare slides 31–33)
|
|
99
|
+
|
|
100
|
+
Simulated hourly temperature and power price, with the price rising in both cold and hot weather, much as in the temperature and day-ahead price application of the talk. The map is blue on the cold side and red on the hot side, whereas a map of absolute deviations would show both sides as a single region of strong dependence. Both series are strongly autocorrelated, so the map is computed with `serial="hac"`. This is the figure at the top of the page.
|
|
101
|
+
|
|
102
|
+
### Lower-tail dependence (compare slides 34–36)
|
|
103
|
+
|
|
104
|
+
Clayton copula with θ = 2, where dependence is strongest in the joint lower tail. The load and day-ahead price application of the talk shows a similar asymmetry.
|
|
105
|
+
|
|
106
|
+

|
|
107
|
+
|
|
108
|
+
## The statistic
|
|
109
|
+
|
|
110
|
+
For a sample (X₁, Y₁), …, (Xₙ, Yₙ) with empirical copula Cₙ, the map is
|
|
111
|
+
|
|
112
|
+
Z(u, v) = √n (Cₙ(u, v) − uv) / √(u(1 − u) v(1 − v)).
|
|
113
|
+
|
|
114
|
+
When there are ties, u and v are replaced by the largest attainable marginal levels not exceeding them, which keeps the statistic exact.
|
|
115
|
+
|
|
116
|
+
The map is intuitive and easy to read since:
|
|
117
|
+
|
|
118
|
+
1. Z(u, v) is a statistic for a 2×2 table. If X is dichotomized at its u-quantile and Y at its v-quantile, then Z(u, v) = √n φ(u, v), where φ is the phi coefficient of the resulting table, and Z² is its Pearson χ² statistic. φ does not depend on the sample size and is available as `dm.phi`. Note, however, that the margins of the table bound |φ| from above. The bound equals 1 on the diagonal u = v but falls quickly away from it, and at (0.1, 0.9) φ cannot exceed 1/9 even under perfect dependence. `dm.phi_rel` = φ/φ_max corrects for this and equals ±1 everywhere under perfect positive or negative dependence.
|
|
119
|
+
2. Under independence and i.i.d. sampling, Z(u, v) is asymptotically N(0, 1) at every point, and the map as a whole converges to a Gaussian field with covariance ρ(u, u′)ρ(v, v′), where ρ(s, t) = (s∧t − st)/√(s(1 − s)t(1 − t)). This covariance contains no unknown quantities, so simultaneous critical values can be obtained by simulation, without resampling. Continuous margins are not required.
|
|
120
|
+
3. The sign carries information. Red cells indicate positive quadrant dependence at (u, v), meaning that the joint lower and joint upper orthants contain more observations than under independence, while blue cells indicate the opposite. A map of |Z| cannot distinguish the two and shows a U-shaped relationship as a single region of strong dependence.
|
|
121
|
+
|
|
122
|
+
The raw deviation Cₙ − uv is also available (`which="raw"`), but we do not recommend using it on its own. By the Fréchet bounds it tends to zero at the edges of the unit square regardless of the strength of dependence, which exaggerates dependence near the median.
|
|
123
|
+
|
|
124
|
+
## Usage
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
import depmaps as dpm
|
|
128
|
+
|
|
129
|
+
dm = dpm.dependence_map(x, y, grid=19) # levels 0.05, ..., 0.95
|
|
130
|
+
dm.simultaneous_band(alpha=0.05) # critical value for max |Z|
|
|
131
|
+
dm.plot() # signed map; dots mark non-significant cells
|
|
132
|
+
dm.plot(which="phi_rel") # effect size relative to its bound
|
|
133
|
+
dm.to_frame() # map as a table (needs pandas)
|
|
134
|
+
dm.global_pvalue # sup-norm test of independence
|
|
135
|
+
|
|
136
|
+
# serially dependent data
|
|
137
|
+
dm = dpm.dependence_map("load", "price", data=df, grid=19,
|
|
138
|
+
serial="hac") # lag by the Andrews (1991) rule
|
|
139
|
+
dm = dpm.dependence_map("load", "price", data=df, grid=19,
|
|
140
|
+
serial="hac", max_lag=168) # or a fixed lag
|
|
141
|
+
dm.simultaneous_band(method="gaussian") # HAC Gaussian null
|
|
142
|
+
dm.simultaneous_band(method="shift") # circular shifts of Y
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## Inference
|
|
146
|
+
|
|
147
|
+
Simultaneous critical values can be computed in four ways:
|
|
148
|
+
|
|
149
|
+
| method | null distribution | valid for |
|
|
150
|
+
|---|---|---|
|
|
151
|
+
| `gaussian` | Gaussian field with known covariance | i.i.d. data |
|
|
152
|
+
| `gaussian` with `serial="hac"` | Gaussian field with covariance Σ_h w_h Γ_A(h) ⊗ Γ_B(h), estimated | stationary, weakly dependent series |
|
|
153
|
+
| `permutation` | random permutations of Y | i.i.d. data, exact under ties |
|
|
154
|
+
| `shift` | random circular shifts of Y | stationary series |
|
|
155
|
+
|
|
156
|
+
The HAC Gaussian null uses a dense covariance matrix over all pairs of grid cells and is therefore limited to 2,500 cells (a 49×49 grid). For finer grids, use `shift`.
|
|
157
|
+
|
|
158
|
+
Under serial dependence, the pointwise null variance of √n(Cₙ − uv) equals Σ_h γ_A(h)γ_B(h), where A_t = 1{U_t ≤ u} − u and B_t = 1{V_t ≤ v} − v are centred indicator series. With `serial="hac"`, the ratio κ(u, v) of this long-run variance to its i.i.d. counterpart is estimated with Bartlett weights and the map reports Z/√κ. The truncation lag is chosen by the AR(1) plug-in rule of Andrews (1991) applied to A_t B_t. The Newey–West rule of thumb is available as `max_lag="nw"`, but it is much too short for persistent series such as hourly prices. For persistent and seasonal series κ can be large and may keep growing with the lag, so results should be reported for several lags.
|
|
159
|
+
|
|
160
|
+
## Tests
|
|
161
|
+
|
|
162
|
+
Running `pytest` checks both the code and the underlying theory. Among other things, the tests verify that:
|
|
163
|
+
|
|
164
|
+
- the raw deviation at the centre of the Gaussian copula equals arcsin(ρ)/2π, and Z² equals the χ² statistic of the corresponding 2×2 table;
|
|
165
|
+
- the statistic remains exact under ties and is standard normal under independence;
|
|
166
|
+
- the simultaneous band has the correct size, and Gaussian and permutation critical values agree;
|
|
167
|
+
- the map detects dependence that is confined to the tails;
|
|
168
|
+
- for AR(1) series, the HAC correction restores unit null variance and gives a band of approximately correct size;
|
|
169
|
+
- φ/φ_max equals ±1 under perfect dependence, and the plug-in lag increases with persistence;
|
|
170
|
+
- the copula samplers have uniform margins and the correct Kendall's τ;
|
|
171
|
+
- invalid input (grid and lag arguments, misaligned indexes) is rejected with a clear error.
|
|
172
|
+
|
|
173
|
+
## Citing
|
|
174
|
+
|
|
175
|
+
If you use the package, please cite:
|
|
176
|
+
|
|
177
|
+
> Medovikov, I. (2024). *Dependence Maps: A Graphical Tool for Visualizing Dependence.* 18th International Conference on Computational and Financial Econometrics (CFE-CMStatistics 2024), King's College London, 14–16 December 2024.
|
|
178
|
+
|
|
179
|
+
The repository includes a `CITATION.cff` file, which GitHub uses for its "Cite this repository" button.
|
|
180
|
+
|
|
181
|
+
## License
|
|
182
|
+
|
|
183
|
+
MIT; see [`LICENSE`](https://github.com/Brainologist/dependence-maps/blob/main/LICENSE).
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
dependence_maps-0.1.1.dist-info/licenses/LICENSE,sha256=hfjQWbvhjvxQrzxohAWnD2h8--vxE_EbzqTWCqIi8-E,1071
|
|
2
|
+
depmaps/__init__.py,sha256=gGiZTWYKMy28-EJNlKWRhJRaYJBhwH6NGFdupcwjQOI,635
|
|
3
|
+
depmaps/core.py,sha256=Iz4ofn7VfSpHfehJDqFfq07HDuGsAti-8DyzuaUl1Go,14778
|
|
4
|
+
depmaps/inference.py,sha256=8gKU_qwmL1aDYSJvhuZVLIHH-qnoSjFdfRoNH6K5nQU,6112
|
|
5
|
+
depmaps/plotting.py,sha256=5MngEhh5LJCWyV24uIUFx5Z_0Ho2nJ2Zsz45cRx-hZ0,6457
|
|
6
|
+
depmaps/simulate.py,sha256=IvU11dypz2iKWiFp2mUpic8QaE0-HpQPrpvvUxPkRYw,1266
|
|
7
|
+
dependence_maps-0.1.1.dist-info/METADATA,sha256=JbfA4sq1wUnPT2AnXHJ4Jai_orgj5IFWoWwOqV8Y-5A,11847
|
|
8
|
+
dependence_maps-0.1.1.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
9
|
+
dependence_maps-0.1.1.dist-info/top_level.txt,sha256=LlKEz93XkIYGTB-8GSX8PKBznPWyQG1PiW8kqF8lbNU,8
|
|
10
|
+
dependence_maps-0.1.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ivan Medovikov
|
|
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 @@
|
|
|
1
|
+
depmaps
|
depmaps/__init__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Dependence maps: signed standardized deviations of the empirical copula from independence."""
|
|
2
|
+
from .core import DependenceMap, dependence_map, make_grid, pseudo_obs, default_max_lag, andrews_max_lag
|
|
3
|
+
from .inference import null_covariance, null_max_distribution, simultaneous_band
|
|
4
|
+
from .plotting import DEPMAP_CMAP, plot_map, rank_scatter
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"DependenceMap",
|
|
8
|
+
"dependence_map",
|
|
9
|
+
"make_grid",
|
|
10
|
+
"pseudo_obs",
|
|
11
|
+
"default_max_lag",
|
|
12
|
+
"andrews_max_lag",
|
|
13
|
+
"simultaneous_band",
|
|
14
|
+
"null_max_distribution",
|
|
15
|
+
"null_covariance",
|
|
16
|
+
"plot_map",
|
|
17
|
+
"rank_scatter",
|
|
18
|
+
"DEPMAP_CMAP",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
__version__ = "0.1.1"
|
depmaps/core.py
ADDED
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
"""Core computation of dependence maps.
|
|
2
|
+
|
|
3
|
+
A dependence map evaluates, on a grid of quantile levels (u, v), the signed
|
|
4
|
+
standardized deviation of the empirical copula from the independence copula
|
|
5
|
+
|
|
6
|
+
Z(u, v) = sqrt(n) * (C_n(a, b) - a b) / sqrt(a (1 - a) b (1 - b)),
|
|
7
|
+
|
|
8
|
+
where a = a(u) and b = b(v) are the marginal proportions actually attained
|
|
9
|
+
when X is cut at its empirical u-quantile and Y at its empirical v-quantile.
|
|
10
|
+
Without ties a = floor(n u) / n -> u; with ties a is the largest attainable
|
|
11
|
+
level not exceeding u, which keeps the statistic exact. The Gaussian limit
|
|
12
|
+
below does not require continuous margins.
|
|
13
|
+
|
|
14
|
+
Equivalently, Z(u, v) = sqrt(n) * phi(u, v), where phi is the phi coefficient
|
|
15
|
+
of the 2x2 contingency table obtained by dichotomizing X at its u-quantile and
|
|
16
|
+
Y at its v-quantile, and Z(u, v)^2 is that table's Pearson chi-square
|
|
17
|
+
statistic. Under independence (i.i.d. sampling) Z(u, v) is asymptotically
|
|
18
|
+
N(0, 1) pointwise, and the field {Z(u, v)} converges to a centred Gaussian
|
|
19
|
+
field with covariance
|
|
20
|
+
|
|
21
|
+
rho_a(u, u') * rho_b(v, v'), rho(s, t) = (s ^ t - s t) / sqrt(s(1-s) t(1-t)),
|
|
22
|
+
|
|
23
|
+
i.e. a normalized tied-down Brownian pillow.
|
|
24
|
+
|
|
25
|
+
For serially dependent observations the pointwise null variance of
|
|
26
|
+
sqrt(n)(C_n - ab) is sum_h gamma_A(h) gamma_B(h), where A_t, B_t are the
|
|
27
|
+
centred indicator series. ``serial="hac"`` estimates the ratio of this
|
|
28
|
+
long-run variance to the i.i.d. variance with Bartlett weights and rescales Z.
|
|
29
|
+
"""
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
from dataclasses import dataclass, field
|
|
33
|
+
from typing import Optional, Sequence, Tuple, Union
|
|
34
|
+
|
|
35
|
+
import numpy as np
|
|
36
|
+
|
|
37
|
+
GridSpec = Union[int, Sequence[float], np.ndarray]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
# helpers
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
def pseudo_obs(x: np.ndarray) -> np.ndarray:
|
|
45
|
+
"""Empirical CDF values F_n(x_i) = #{j : x_j <= x_i} / n ("max" ranks / n).
|
|
46
|
+
|
|
47
|
+
Ties receive the same value, the largest rank in their group, so that
|
|
48
|
+
{F_n(X_i) <= u} is exactly the event {X_i <= empirical u-quantile}.
|
|
49
|
+
"""
|
|
50
|
+
x = np.asarray(x, dtype=float)
|
|
51
|
+
n = x.shape[0]
|
|
52
|
+
order = np.sort(x)
|
|
53
|
+
return np.searchsorted(order, x, side="right") / n
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def make_grid(grid: GridSpec) -> np.ndarray:
|
|
57
|
+
"""Quantile levels strictly inside (0, 1).
|
|
58
|
+
|
|
59
|
+
An integer g gives the equispaced interior grid (1, ..., g) / (g + 1).
|
|
60
|
+
"""
|
|
61
|
+
if np.isscalar(grid):
|
|
62
|
+
if isinstance(grid, (bool, np.bool_)) or not float(grid).is_integer():
|
|
63
|
+
raise ValueError(
|
|
64
|
+
f"grid={grid!r}: a scalar grid must be a whole number of levels; "
|
|
65
|
+
"pass quantile levels as a list or array, e.g. grid=[0.25, 0.75]"
|
|
66
|
+
)
|
|
67
|
+
g = int(grid)
|
|
68
|
+
if g < 1:
|
|
69
|
+
raise ValueError("grid size must be >= 1")
|
|
70
|
+
return np.arange(1, g + 1) / (g + 1)
|
|
71
|
+
levels = np.sort(np.asarray(grid, dtype=float).ravel())
|
|
72
|
+
if np.any(levels <= 0) or np.any(levels >= 1):
|
|
73
|
+
raise ValueError("grid levels must lie strictly inside (0, 1)")
|
|
74
|
+
return levels
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _indicators(F: np.ndarray, levels: np.ndarray) -> np.ndarray:
|
|
78
|
+
"""n x g matrix of 1{F_n(X_i) <= level_k} as float64."""
|
|
79
|
+
return (F[:, None] <= levels[None, :] + 1e-12).astype(float)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _acf_columns(M: np.ndarray, max_lag: int) -> np.ndarray:
|
|
83
|
+
"""Autocorrelations of each (centred) column of M for lags 1..max_lag.
|
|
84
|
+
|
|
85
|
+
Returns an array of shape (max_lag, g). Uses FFT; columns with zero
|
|
86
|
+
variance get zero autocorrelation.
|
|
87
|
+
"""
|
|
88
|
+
n, g = M.shape
|
|
89
|
+
Mc = M - M.mean(axis=0, keepdims=True)
|
|
90
|
+
nfft = 1 << int(np.ceil(np.log2(2 * n - 1)))
|
|
91
|
+
f = np.fft.rfft(Mc, n=nfft, axis=0)
|
|
92
|
+
ac = np.fft.irfft(f * np.conj(f), n=nfft, axis=0)[: max_lag + 1]
|
|
93
|
+
var = ac[0]
|
|
94
|
+
with np.errstate(invalid="ignore", divide="ignore"):
|
|
95
|
+
r = np.where(var > 0, ac[1:] / var, 0.0)
|
|
96
|
+
return r
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def default_max_lag(n: int) -> int:
|
|
100
|
+
"""Newey-West (1994) style rule of thumb, floor(4 (n/100)^(2/9))."""
|
|
101
|
+
return int(np.floor(4 * (n / 100.0) ** (2.0 / 9.0)))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def andrews_max_lag(A: np.ndarray, B: np.ndarray) -> int:
|
|
105
|
+
"""Andrews (1991) AR(1) plug-in truncation lag for the Bartlett kernel.
|
|
106
|
+
|
|
107
|
+
The long-run variance of interest is that of the products A_t B_t, whose
|
|
108
|
+
first-order autocorrelation under independence is rho_A * rho_B. The
|
|
109
|
+
plug-in uses the median of these products over the grid:
|
|
110
|
+
L = 1.1447 (alpha n)^(1/3), alpha = 4 rho^2 / (1 - rho^2)^2.
|
|
111
|
+
"""
|
|
112
|
+
n = A.shape[0]
|
|
113
|
+
rA = _acf_columns(A, 1)[0]
|
|
114
|
+
rB = _acf_columns(B, 1)[0]
|
|
115
|
+
rho = float(np.median(np.abs(np.outer(rA, rB))))
|
|
116
|
+
rho = min(rho, 0.99)
|
|
117
|
+
alpha = 4.0 * rho**2 / (1.0 - rho**2) ** 2
|
|
118
|
+
L = int(np.ceil(1.1447 * (alpha * n) ** (1.0 / 3.0)))
|
|
119
|
+
return max(1, min(L, n // 4))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def bartlett_weights(max_lag: int) -> np.ndarray:
|
|
123
|
+
h = np.arange(1, max_lag + 1)
|
|
124
|
+
return 1.0 - h / (max_lag + 1.0)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# ---------------------------------------------------------------------------
|
|
128
|
+
# result container
|
|
129
|
+
# ---------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
@dataclass
|
|
132
|
+
class DependenceMap:
|
|
133
|
+
"""Result of :func:`dependence_map`.
|
|
134
|
+
|
|
135
|
+
Attributes
|
|
136
|
+
----------
|
|
137
|
+
u, v : nominal quantile levels kept on each axis (after dropping levels
|
|
138
|
+
that collapse onto the same attainable proportion because of ties).
|
|
139
|
+
a, b : attained marginal proportions at those levels (equal u, v up to
|
|
140
|
+
O(1/n) without ties). Maps are plotted against ``a`` and ``b``.
|
|
141
|
+
n : number of complete observations used.
|
|
142
|
+
raw : signed raw deviation C_n(a, b) - a b, shape (len(a), len(b)).
|
|
143
|
+
Rows index X levels, columns index Y levels.
|
|
144
|
+
z_iid : sqrt(n) * raw / sqrt(a(1-a) b(1-b)), the i.i.d. standardization.
|
|
145
|
+
kappa : long-run variance inflation factor (None unless serial="hac").
|
|
146
|
+
z : the map to read; z_iid / sqrt(kappa) if serial correction is on.
|
|
147
|
+
"""
|
|
148
|
+
|
|
149
|
+
u: np.ndarray
|
|
150
|
+
v: np.ndarray
|
|
151
|
+
a: np.ndarray
|
|
152
|
+
b: np.ndarray
|
|
153
|
+
n: int
|
|
154
|
+
raw: np.ndarray
|
|
155
|
+
z_iid: np.ndarray
|
|
156
|
+
kappa: Optional[np.ndarray] = None
|
|
157
|
+
max_lag: Optional[int] = None
|
|
158
|
+
xlabel: str = "X"
|
|
159
|
+
ylabel: str = "Y"
|
|
160
|
+
# kept for inference (indicator matrices); not part of the public repr
|
|
161
|
+
_A: Optional[np.ndarray] = field(default=None, repr=False)
|
|
162
|
+
_B: Optional[np.ndarray] = field(default=None, repr=False)
|
|
163
|
+
critical_value: Optional[float] = None
|
|
164
|
+
critical_method: Optional[str] = None
|
|
165
|
+
alpha: Optional[float] = None
|
|
166
|
+
global_pvalue: Optional[float] = None
|
|
167
|
+
|
|
168
|
+
@property
|
|
169
|
+
def z(self) -> np.ndarray:
|
|
170
|
+
if self.kappa is None:
|
|
171
|
+
return self.z_iid
|
|
172
|
+
return self.z_iid / np.sqrt(self.kappa)
|
|
173
|
+
|
|
174
|
+
@property
|
|
175
|
+
def phi(self) -> np.ndarray:
|
|
176
|
+
"""Effect-size map: the phi coefficient of each dichotomized 2x2 table.
|
|
177
|
+
|
|
178
|
+
phi = raw / sqrt(a(1-a) b(1-b)) = z_iid / sqrt(n). Comparable across
|
|
179
|
+
samples of different size, in [-1, 1].
|
|
180
|
+
"""
|
|
181
|
+
return self.raw / np.sqrt(np.outer(self.a * (1 - self.a), self.b * (1 - self.b)))
|
|
182
|
+
|
|
183
|
+
@property
|
|
184
|
+
def phi_max(self) -> np.ndarray:
|
|
185
|
+
"""Largest |phi| attainable given the margins, for the sign of each cell.
|
|
186
|
+
|
|
187
|
+
For phi >= 0 the bound is sqrt(min(a,b)(1-max(a,b)) / (max(a,b)(1-min(a,b))));
|
|
188
|
+
for phi < 0 it is sqrt(ab / ((1-a)(1-b))) when a + b <= 1 and
|
|
189
|
+
sqrt((1-a)(1-b) / (ab)) otherwise. Off the diagonal the bound is well
|
|
190
|
+
below one, so phi maps fade away from the diagonal even under perfect
|
|
191
|
+
dependence.
|
|
192
|
+
"""
|
|
193
|
+
A, B = np.meshgrid(self.a, self.b, indexing="ij")
|
|
194
|
+
lo, hi = np.minimum(A, B), np.maximum(A, B)
|
|
195
|
+
pos = np.sqrt(lo * (1 - hi) / (hi * (1 - lo)))
|
|
196
|
+
neg = np.where(A + B <= 1, np.sqrt(A * B / ((1 - A) * (1 - B))),
|
|
197
|
+
np.sqrt((1 - A) * (1 - B) / (A * B)))
|
|
198
|
+
return np.where(self.raw >= 0, pos, neg)
|
|
199
|
+
|
|
200
|
+
@property
|
|
201
|
+
def phi_rel(self) -> np.ndarray:
|
|
202
|
+
"""phi / phi_max: phi relative to its attainable bound, in [-1, 1].
|
|
203
|
+
|
|
204
|
+
Equals +1 everywhere under comonotonicity and -1 under
|
|
205
|
+
countermonotonicity, regardless of (u, v).
|
|
206
|
+
"""
|
|
207
|
+
return self.phi / self.phi_max
|
|
208
|
+
|
|
209
|
+
@property
|
|
210
|
+
def pvalues(self) -> np.ndarray:
|
|
211
|
+
"""Pointwise two-sided p-values from the N(0, 1) approximation."""
|
|
212
|
+
from scipy.stats import norm
|
|
213
|
+
|
|
214
|
+
return 2.0 * norm.sf(np.abs(self.z))
|
|
215
|
+
|
|
216
|
+
@property
|
|
217
|
+
def max_abs_z(self) -> float:
|
|
218
|
+
return float(np.nanmax(np.abs(self.z)))
|
|
219
|
+
|
|
220
|
+
def significant(self) -> np.ndarray:
|
|
221
|
+
"""Boolean mask of cells exceeding the simultaneous critical value."""
|
|
222
|
+
if self.critical_value is None:
|
|
223
|
+
raise ValueError("call simultaneous_band(...) first")
|
|
224
|
+
return np.abs(self.z) > self.critical_value
|
|
225
|
+
|
|
226
|
+
def to_frame(self, which: str = "z"):
|
|
227
|
+
"""Return the map as a pandas DataFrame (rows: X levels, cols: Y levels)."""
|
|
228
|
+
import pandas as pd
|
|
229
|
+
|
|
230
|
+
M = {"z": self.z, "z_iid": self.z_iid, "raw": self.raw, "phi": self.phi,
|
|
231
|
+
"phi_rel": self.phi_rel}[which]
|
|
232
|
+
return pd.DataFrame(
|
|
233
|
+
M,
|
|
234
|
+
index=pd.Index(np.round(self.a, 4), name=f"{self.xlabel} level"),
|
|
235
|
+
columns=pd.Index(np.round(self.b, 4), name=f"{self.ylabel} level"),
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
def plot(self, **kwargs):
|
|
239
|
+
from .plotting import plot_map
|
|
240
|
+
|
|
241
|
+
return plot_map(self, **kwargs)
|
|
242
|
+
|
|
243
|
+
def simultaneous_band(self, **kwargs) -> float:
|
|
244
|
+
from .inference import simultaneous_band
|
|
245
|
+
|
|
246
|
+
return simultaneous_band(self, **kwargs)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
# ---------------------------------------------------------------------------
|
|
250
|
+
# main entry point
|
|
251
|
+
# ---------------------------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
def _as_array_and_label(obj, default):
|
|
254
|
+
name = getattr(obj, "name", None)
|
|
255
|
+
return np.asarray(obj, dtype=float).ravel(), (str(name) if name is not None else default)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def dependence_map(
|
|
259
|
+
x,
|
|
260
|
+
y=None,
|
|
261
|
+
grid: Union[GridSpec, Tuple[GridSpec, GridSpec]] = 19,
|
|
262
|
+
*,
|
|
263
|
+
data=None,
|
|
264
|
+
serial: Optional[str] = None,
|
|
265
|
+
max_lag: Union[int, str, None] = "auto",
|
|
266
|
+
keep_indicators: bool = True,
|
|
267
|
+
) -> DependenceMap:
|
|
268
|
+
"""Compute a signed, standardized dependence map.
|
|
269
|
+
|
|
270
|
+
Parameters
|
|
271
|
+
----------
|
|
272
|
+
x, y : array-like of equal length, or column names when ``data`` is given.
|
|
273
|
+
Pairs with a missing value in either coordinate are dropped.
|
|
274
|
+
grid : int, list/array of levels, or a tuple ``(x_grid, y_grid)`` of these.
|
|
275
|
+
Default 19 gives levels 0.05, 0.10, ..., 0.95.
|
|
276
|
+
data : optional DataFrame from which ``x`` and ``y`` are taken.
|
|
277
|
+
serial : None (i.i.d. standardization) or "hac" (Bartlett long-run
|
|
278
|
+
variance correction for serially dependent data).
|
|
279
|
+
max_lag : truncation lag for ``serial="hac"``. "auto" (default) uses the
|
|
280
|
+
Andrews (1991) AR(1) plug-in rule (:func:`andrews_max_lag`); "nw"
|
|
281
|
+
uses the Newey-West rule of thumb (:func:`default_max_lag`), which is
|
|
282
|
+
far too short for persistent data such as hourly prices; an integer
|
|
283
|
+
sets the lag directly. Because the long-run variance of seasonal
|
|
284
|
+
series may not settle as the lag grows, results should be reported
|
|
285
|
+
for several lags.
|
|
286
|
+
keep_indicators : store indicator matrices, needed by
|
|
287
|
+
:func:`depmaps.simultaneous_band` and the permutation/shift methods.
|
|
288
|
+
"""
|
|
289
|
+
if data is not None:
|
|
290
|
+
xlab, ylab = str(x), str(y)
|
|
291
|
+
x, y = data[x], data[y]
|
|
292
|
+
if y is None:
|
|
293
|
+
raise ValueError("y is required")
|
|
294
|
+
ix, iy = getattr(x, "index", None), getattr(y, "index", None)
|
|
295
|
+
if hasattr(ix, "equals") and hasattr(iy, "equals") and not ix.equals(iy):
|
|
296
|
+
raise ValueError("x and y have different indexes; align them before calling")
|
|
297
|
+
xv, xlab_ = _as_array_and_label(x, "X")
|
|
298
|
+
yv, ylab_ = _as_array_and_label(y, "Y")
|
|
299
|
+
if data is None:
|
|
300
|
+
xlab, ylab = xlab_, ylab_
|
|
301
|
+
if xv.shape != yv.shape:
|
|
302
|
+
raise ValueError("x and y must have the same length")
|
|
303
|
+
|
|
304
|
+
ok = np.isfinite(xv) & np.isfinite(yv)
|
|
305
|
+
n_dropped = int((~ok).sum())
|
|
306
|
+
if serial is not None and n_dropped:
|
|
307
|
+
import warnings
|
|
308
|
+
|
|
309
|
+
warnings.warn(
|
|
310
|
+
f"{n_dropped} incomplete observations dropped; the remaining series is "
|
|
311
|
+
"treated as contiguous when estimating serial dependence",
|
|
312
|
+
stacklevel=2,
|
|
313
|
+
)
|
|
314
|
+
xv, yv = xv[ok], yv[ok]
|
|
315
|
+
n = xv.shape[0]
|
|
316
|
+
if n < 10:
|
|
317
|
+
raise ValueError("need at least 10 complete observations")
|
|
318
|
+
|
|
319
|
+
if isinstance(grid, tuple):
|
|
320
|
+
if len(grid) != 2:
|
|
321
|
+
raise ValueError("a tuple grid must be (x_grid, y_grid); pass levels as a list or array")
|
|
322
|
+
gx, gy = make_grid(grid[0]), make_grid(grid[1])
|
|
323
|
+
else:
|
|
324
|
+
gx = gy = make_grid(grid)
|
|
325
|
+
|
|
326
|
+
U, V = pseudo_obs(xv), pseudo_obs(yv)
|
|
327
|
+
A, B = _indicators(U, gx), _indicators(V, gy)
|
|
328
|
+
a, b = A.mean(axis=0), B.mean(axis=0)
|
|
329
|
+
|
|
330
|
+
# drop levels that are degenerate (a in {0, 1}) or duplicate because of ties
|
|
331
|
+
def _keep(p):
|
|
332
|
+
valid = (p > 0) & (p < 1)
|
|
333
|
+
idx = np.flatnonzero(valid)
|
|
334
|
+
_, first = np.unique(np.round(p[idx], 12), return_index=True)
|
|
335
|
+
return np.sort(idx[first])
|
|
336
|
+
|
|
337
|
+
kx, ky = _keep(a), _keep(b)
|
|
338
|
+
if kx.size == 0 or ky.size == 0:
|
|
339
|
+
raise ValueError("no non-degenerate grid levels (variable is constant?)")
|
|
340
|
+
gx, gy, A, B, a, b = gx[kx], gy[ky], A[:, kx], B[:, ky], a[kx], b[ky]
|
|
341
|
+
|
|
342
|
+
p11 = (A.T @ B) / n
|
|
343
|
+
raw = p11 - np.outer(a, b)
|
|
344
|
+
scale = np.sqrt(np.outer(a * (1 - a), b * (1 - b)))
|
|
345
|
+
z_iid = np.sqrt(n) * raw / scale
|
|
346
|
+
|
|
347
|
+
kappa = None
|
|
348
|
+
L = None
|
|
349
|
+
if serial is None and max_lag not in (None, "auto"):
|
|
350
|
+
import warnings
|
|
351
|
+
|
|
352
|
+
warnings.warn("max_lag is ignored unless serial='hac'", stacklevel=2)
|
|
353
|
+
if serial is not None:
|
|
354
|
+
if serial != "hac":
|
|
355
|
+
raise ValueError("serial must be None or 'hac'")
|
|
356
|
+
if max_lag is None or max_lag == "auto":
|
|
357
|
+
L = andrews_max_lag(A, B)
|
|
358
|
+
elif max_lag == "nw":
|
|
359
|
+
L = default_max_lag(n)
|
|
360
|
+
elif isinstance(max_lag, (int, np.integer)) and not isinstance(max_lag, bool):
|
|
361
|
+
L = int(max_lag)
|
|
362
|
+
if L < 0:
|
|
363
|
+
raise ValueError("max_lag must be non-negative")
|
|
364
|
+
else:
|
|
365
|
+
raise ValueError("max_lag must be 'auto', 'nw' or a non-negative integer")
|
|
366
|
+
if L >= n:
|
|
367
|
+
raise ValueError("max_lag must be smaller than the number of observations")
|
|
368
|
+
if L >= 1:
|
|
369
|
+
w = bartlett_weights(L)
|
|
370
|
+
RA = _acf_columns(A, L) # (L, gx)
|
|
371
|
+
RB = _acf_columns(B, L) # (L, gy)
|
|
372
|
+
kappa = 1.0 + 2.0 * (RA * w[:, None]).T @ RB
|
|
373
|
+
kappa = np.maximum(kappa, 1e-3)
|
|
374
|
+
else:
|
|
375
|
+
kappa = np.ones_like(z_iid)
|
|
376
|
+
|
|
377
|
+
return DependenceMap(
|
|
378
|
+
u=gx, v=gy, a=a, b=b, n=n, raw=raw, z_iid=z_iid, kappa=kappa,
|
|
379
|
+
max_lag=L, xlabel=xlab, ylabel=ylab,
|
|
380
|
+
_A=A if keep_indicators else None, _B=B if keep_indicators else None,
|
|
381
|
+
)
|
depmaps/inference.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Simultaneous inference for dependence maps.
|
|
2
|
+
|
|
3
|
+
All methods calibrate the distribution of max_{(u,v) in grid} |Z(u, v)| under
|
|
4
|
+
the null of independence between X and Y, giving a critical value c_alpha
|
|
5
|
+
such that, under the null, P(max |Z| > c_alpha) ~= alpha. Cells with
|
|
6
|
+
|Z| > c_alpha are significant with family-wise error rate alpha.
|
|
7
|
+
|
|
8
|
+
Methods
|
|
9
|
+
-------
|
|
10
|
+
gaussian Simulate the limiting Gaussian field. For i.i.d. data its
|
|
11
|
+
covariance is the Kronecker product of normalized Brownian-bridge
|
|
12
|
+
kernels in the attained levels (no unknowns). With a HAC-corrected
|
|
13
|
+
map (serial="hac") the covariance is
|
|
14
|
+
sum_h w_h Gamma_A(h) (x) Gamma_B(h), estimated from the
|
|
15
|
+
indicator series, then normalized to unit diagonal.
|
|
16
|
+
permutation Randomly permute Y relative to X. Exact for i.i.d. data and
|
|
17
|
+
handles ties; invalid for serially dependent data.
|
|
18
|
+
shift Random circular shifts of Y relative to X (toroidal shifts).
|
|
19
|
+
Preserves each series' own serial dependence and breaks the
|
|
20
|
+
cross-dependence; the natural resampling null for stationary
|
|
21
|
+
time series.
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import warnings
|
|
26
|
+
from typing import Optional
|
|
27
|
+
|
|
28
|
+
import numpy as np
|
|
29
|
+
|
|
30
|
+
from .core import DependenceMap, bartlett_weights
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
MAX_HAC_CELLS = 2500
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _bridge_corr(p: np.ndarray) -> np.ndarray:
|
|
37
|
+
s = np.sqrt(p * (1 - p))
|
|
38
|
+
return (np.minimum.outer(p, p) - np.outer(p, p)) / np.outer(s, s)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _psd_factor(S: np.ndarray) -> np.ndarray:
|
|
42
|
+
"""Symmetric square-root factor of a (nearly) PSD matrix."""
|
|
43
|
+
S = 0.5 * (S + S.T)
|
|
44
|
+
w, V = np.linalg.eigh(S)
|
|
45
|
+
w = np.clip(w, 0.0, None)
|
|
46
|
+
return V * np.sqrt(w)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _lagged_cov(Mc: np.ndarray, h: int) -> np.ndarray:
|
|
50
|
+
n = Mc.shape[0]
|
|
51
|
+
if h == 0:
|
|
52
|
+
return Mc.T @ Mc / n
|
|
53
|
+
return Mc[:-h].T @ Mc[h:] / n
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def null_covariance(dm: DependenceMap) -> np.ndarray:
|
|
57
|
+
"""Null correlation matrix of vec(Z) (row-major over the grid)."""
|
|
58
|
+
if dm.kappa is None:
|
|
59
|
+
return np.kron(_bridge_corr(dm.a), _bridge_corr(dm.b))
|
|
60
|
+
cells = dm.a.size * dm.b.size
|
|
61
|
+
if cells > MAX_HAC_CELLS:
|
|
62
|
+
raise ValueError(
|
|
63
|
+
f"the HAC Gaussian null needs a dense {cells}x{cells} covariance matrix; "
|
|
64
|
+
f"use a grid with at most {MAX_HAC_CELLS} cells (e.g. 49x49) or method='shift'"
|
|
65
|
+
)
|
|
66
|
+
if dm._A is None or dm._B is None:
|
|
67
|
+
raise ValueError("map was computed with keep_indicators=False")
|
|
68
|
+
Ac = dm._A - dm.a
|
|
69
|
+
Bc = dm._B - dm.b
|
|
70
|
+
S = np.kron(_lagged_cov(Ac, 0), _lagged_cov(Bc, 0))
|
|
71
|
+
for h, w in zip(range(1, dm.max_lag + 1), bartlett_weights(dm.max_lag)):
|
|
72
|
+
GA, GB = _lagged_cov(Ac, h), _lagged_cov(Bc, h)
|
|
73
|
+
S += w * (np.kron(GA, GB) + np.kron(GA.T, GB.T))
|
|
74
|
+
d = np.sqrt(np.clip(np.diag(S), 1e-300, None))
|
|
75
|
+
return S / np.outer(d, d)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _max_abs_z_from_B(dm: DependenceMap, Bp: np.ndarray) -> float:
|
|
79
|
+
n = dm.n
|
|
80
|
+
raw = (dm._A.T @ Bp) / n - np.outer(dm.a, dm.b)
|
|
81
|
+
z = np.sqrt(n) * raw / np.sqrt(np.outer(dm.a * (1 - dm.a), dm.b * (1 - dm.b)))
|
|
82
|
+
if dm.kappa is not None:
|
|
83
|
+
z = z / np.sqrt(dm.kappa)
|
|
84
|
+
return float(np.max(np.abs(z)))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def null_max_distribution(
|
|
88
|
+
dm: DependenceMap,
|
|
89
|
+
method: str = "gaussian",
|
|
90
|
+
n_sim: int = 2000,
|
|
91
|
+
rng: Optional[np.random.Generator] = None,
|
|
92
|
+
min_shift: Optional[int] = None,
|
|
93
|
+
) -> np.ndarray:
|
|
94
|
+
"""Draws of max |Z| over the grid under the null of independence."""
|
|
95
|
+
rng = np.random.default_rng(rng)
|
|
96
|
+
ga, gb = dm.a.size, dm.b.size
|
|
97
|
+
|
|
98
|
+
if method == "gaussian":
|
|
99
|
+
if dm.kappa is None:
|
|
100
|
+
La, Lb = _psd_factor(_bridge_corr(dm.a)), _psd_factor(_bridge_corr(dm.b))
|
|
101
|
+
out = np.empty(n_sim)
|
|
102
|
+
chunk = 500
|
|
103
|
+
for s in range(0, n_sim, chunk):
|
|
104
|
+
k = min(chunk, n_sim - s)
|
|
105
|
+
E = rng.standard_normal((k, La.shape[1], Lb.shape[1]))
|
|
106
|
+
Z = La @ E @ Lb.T
|
|
107
|
+
out[s : s + k] = np.abs(Z).reshape(k, -1).max(axis=1)
|
|
108
|
+
return out
|
|
109
|
+
F = _psd_factor(null_covariance(dm))
|
|
110
|
+
out = np.empty(n_sim)
|
|
111
|
+
chunk = 500
|
|
112
|
+
for s in range(0, n_sim, chunk):
|
|
113
|
+
k = min(chunk, n_sim - s)
|
|
114
|
+
Z = rng.standard_normal((k, F.shape[1])) @ F.T
|
|
115
|
+
out[s : s + k] = np.abs(Z).max(axis=1)
|
|
116
|
+
return out
|
|
117
|
+
|
|
118
|
+
if dm._A is None or dm._B is None:
|
|
119
|
+
raise ValueError("map was computed with keep_indicators=False")
|
|
120
|
+
|
|
121
|
+
if method == "permutation":
|
|
122
|
+
if dm.kappa is not None:
|
|
123
|
+
warnings.warn(
|
|
124
|
+
"permutation destroys serial dependence in Y; use method='shift' "
|
|
125
|
+
"or 'gaussian' for time series",
|
|
126
|
+
stacklevel=2,
|
|
127
|
+
)
|
|
128
|
+
return np.array(
|
|
129
|
+
[_max_abs_z_from_B(dm, dm._B[rng.permutation(dm.n)]) for _ in range(n_sim)]
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
if method == "shift":
|
|
133
|
+
n = dm.n
|
|
134
|
+
m = n // 10 if min_shift is None else int(min_shift)
|
|
135
|
+
if not 0 < m < n // 2:
|
|
136
|
+
raise ValueError("min_shift must be in (0, n/2)")
|
|
137
|
+
shifts = rng.integers(m, n - m + 1, size=n_sim)
|
|
138
|
+
return np.array([_max_abs_z_from_B(dm, np.roll(dm._B, s, axis=0)) for s in shifts])
|
|
139
|
+
|
|
140
|
+
raise ValueError("method must be 'gaussian', 'permutation' or 'shift'")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def simultaneous_band(
|
|
144
|
+
dm: DependenceMap,
|
|
145
|
+
alpha: float = 0.05,
|
|
146
|
+
method: str = "gaussian",
|
|
147
|
+
n_sim: int = 2000,
|
|
148
|
+
rng=None,
|
|
149
|
+
min_shift: Optional[int] = None,
|
|
150
|
+
) -> float:
|
|
151
|
+
"""Simultaneous critical value for |Z| over the grid; stored on ``dm``.
|
|
152
|
+
|
|
153
|
+
Also stores ``dm.global_pvalue``: the share of null draws with
|
|
154
|
+
max |Z| >= the observed max |Z| (a test of independence based on the
|
|
155
|
+
sup-norm of the standardized empirical copula process over the grid).
|
|
156
|
+
"""
|
|
157
|
+
if not 0 < alpha < 1:
|
|
158
|
+
raise ValueError("alpha must lie strictly between 0 and 1")
|
|
159
|
+
draws = null_max_distribution(dm, method=method, n_sim=n_sim, rng=rng, min_shift=min_shift)
|
|
160
|
+
c = float(np.quantile(draws, 1 - alpha))
|
|
161
|
+
dm.critical_value = c
|
|
162
|
+
dm.critical_method = method
|
|
163
|
+
dm.alpha = alpha
|
|
164
|
+
dm.global_pvalue = float((1 + np.sum(draws >= dm.max_abs_z)) / (1 + draws.size))
|
|
165
|
+
return c
|
depmaps/plotting.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Plotting for dependence maps (matplotlib)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
import matplotlib.pyplot as plt
|
|
8
|
+
from matplotlib.colors import LinearSegmentedColormap, TwoSlopeNorm
|
|
9
|
+
|
|
10
|
+
# Diverging blue <-> neutral grey <-> red: negative quadrant dependence is
|
|
11
|
+
# blue, positive is red, independence is the neutral midpoint.
|
|
12
|
+
DEPMAP_CMAP = LinearSegmentedColormap.from_list(
|
|
13
|
+
"depmap",
|
|
14
|
+
["#123a6b", "#2a78d6", "#9fc2ec", "#f0efec", "#f1a3a2", "#e34948", "#7d1716"],
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _edges(p: np.ndarray) -> np.ndarray:
|
|
19
|
+
"""Cell edges for non-uniform levels: midpoints, clipped to [0, 1]."""
|
|
20
|
+
mid = 0.5 * (p[1:] + p[:-1])
|
|
21
|
+
lo = max(0.0, p[0] - (mid[0] - p[0])) if p.size > 1 else max(0.0, p[0] - 0.05)
|
|
22
|
+
hi = min(1.0, p[-1] + (p[-1] - mid[-1])) if p.size > 1 else min(1.0, p[0] + 0.05)
|
|
23
|
+
return np.concatenate([[lo], mid, [hi]])
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def plot_map(
|
|
27
|
+
dm,
|
|
28
|
+
ax: Optional[plt.Axes] = None,
|
|
29
|
+
*,
|
|
30
|
+
which: str = "z",
|
|
31
|
+
absolute: bool = False,
|
|
32
|
+
style: str = "cells",
|
|
33
|
+
vmax: Optional[float] = None,
|
|
34
|
+
show_critical: bool = True,
|
|
35
|
+
pointwise_level: Optional[float] = None,
|
|
36
|
+
colorbar: bool = True,
|
|
37
|
+
xlabel: Optional[str] = None,
|
|
38
|
+
ylabel: Optional[str] = None,
|
|
39
|
+
title: Optional[str] = None,
|
|
40
|
+
cmap=None,
|
|
41
|
+
):
|
|
42
|
+
"""Draw a dependence map.
|
|
43
|
+
|
|
44
|
+
X levels run along the horizontal axis and Y levels along the vertical
|
|
45
|
+
axis, origin at bottom-left (lower tails meet at the origin).
|
|
46
|
+
|
|
47
|
+
Parameters
|
|
48
|
+
----------
|
|
49
|
+
which : "z" (default, standardized), "phi" (effect size, Z / sqrt(n)
|
|
50
|
+
under i.i.d. scaling), "phi_rel" (phi relative to the largest value
|
|
51
|
+
the margins allow, see ``DependenceMap.phi_max``) or "raw" (C_n - ab).
|
|
52
|
+
absolute : plot |Z| with a sequential scale instead of the signed map.
|
|
53
|
+
style : "cells" (one flat cell per grid point; no interpolation) or
|
|
54
|
+
"contour" (filled contours; smoother, reads like the talk figures).
|
|
55
|
+
show_critical : mark simultaneous significance (requires
|
|
56
|
+
``dm.simultaneous_band()`` to have been called). With style="cells"
|
|
57
|
+
a small dot is placed on every cell whose |Z| does not exceed the
|
|
58
|
+
critical value, so the marking follows the grid exactly; with
|
|
59
|
+
style="contour" the |Z| = c contour is drawn instead.
|
|
60
|
+
pointwise_level : optionally also outline pointwise significance at this
|
|
61
|
+
level, e.g. 0.05 -> |Z| > 1.96.
|
|
62
|
+
"""
|
|
63
|
+
if ax is None:
|
|
64
|
+
_, ax = plt.subplots(figsize=(5.2, 4.4))
|
|
65
|
+
maps = {"z": dm.z, "raw": dm.raw, "phi": dm.phi, "phi_rel": dm.phi_rel}
|
|
66
|
+
if which not in maps:
|
|
67
|
+
raise ValueError(f"which must be one of {sorted(maps)}")
|
|
68
|
+
if style == "contour" and (dm.a.size < 2 or dm.b.size < 2):
|
|
69
|
+
raise ValueError("style='contour' needs at least two grid levels on each axis")
|
|
70
|
+
M = maps[which].T # rows -> Y
|
|
71
|
+
a, b = dm.a, dm.b
|
|
72
|
+
|
|
73
|
+
if absolute:
|
|
74
|
+
M = np.abs(M)
|
|
75
|
+
vm = vmax or float(np.nanmax(M))
|
|
76
|
+
cm = cmap or LinearSegmentedColormap.from_list("depmap_abs", ["#f0efec", "#e34948", "#7d1716"])
|
|
77
|
+
norm = plt.Normalize(0, vm)
|
|
78
|
+
else:
|
|
79
|
+
vm = vmax or float(np.nanmax(np.abs(M)))
|
|
80
|
+
if which == "z" and dm.critical_value is not None:
|
|
81
|
+
vm = max(vm, 1.25 * dm.critical_value)
|
|
82
|
+
vm = vm if vm > 0 else 1.0
|
|
83
|
+
cm = cmap or DEPMAP_CMAP
|
|
84
|
+
norm = TwoSlopeNorm(vmin=-vm, vcenter=0.0, vmax=vm)
|
|
85
|
+
|
|
86
|
+
if style == "cells":
|
|
87
|
+
mesh = ax.pcolormesh(_edges(a), _edges(b), M, cmap=cm, norm=norm, shading="flat")
|
|
88
|
+
elif style == "contour":
|
|
89
|
+
levels = np.linspace(norm.vmin, norm.vmax, 21)
|
|
90
|
+
mesh = ax.contourf(a, b, M, levels=levels, cmap=cm, norm=norm, extend="both")
|
|
91
|
+
else:
|
|
92
|
+
raise ValueError("style must be 'cells' or 'contour'")
|
|
93
|
+
|
|
94
|
+
if show_critical and dm.critical_value is not None:
|
|
95
|
+
Zabs = np.abs(dm.z.T)
|
|
96
|
+
if style == "cells":
|
|
97
|
+
jj, ii = np.nonzero(Zabs <= dm.critical_value)
|
|
98
|
+
ax.scatter(a[ii], b[jj], s=3, color="#1a1a19", linewidths=0, zorder=3)
|
|
99
|
+
else:
|
|
100
|
+
ax.contour(a, b, Zabs, levels=[dm.critical_value], colors="#1a1a19",
|
|
101
|
+
linewidths=1.2)
|
|
102
|
+
if which == "z":
|
|
103
|
+
Zabs = np.abs(dm.z.T)
|
|
104
|
+
if pointwise_level is not None:
|
|
105
|
+
from scipy.stats import norm as _n
|
|
106
|
+
|
|
107
|
+
ax.contour(a, b, Zabs, levels=[_n.isf(pointwise_level / 2)], colors="#1a1a19",
|
|
108
|
+
linewidths=0.8, linestyles=":")
|
|
109
|
+
|
|
110
|
+
ax.set_xlim(0, 1)
|
|
111
|
+
ax.set_ylim(0, 1)
|
|
112
|
+
ax.set_aspect("equal")
|
|
113
|
+
ax.set_xticks(np.linspace(0, 1, 6))
|
|
114
|
+
ax.set_yticks(np.linspace(0, 1, 6))
|
|
115
|
+
ax.tick_params(length=2, labelsize=8, colors="#555")
|
|
116
|
+
for s in ax.spines.values():
|
|
117
|
+
s.set_color("#bbb")
|
|
118
|
+
ax.set_xlabel(xlabel or f"{dm.xlabel} (quantile level u)", fontsize=9)
|
|
119
|
+
ax.set_ylabel(ylabel or f"{dm.ylabel} (quantile level v)", fontsize=9)
|
|
120
|
+
if title:
|
|
121
|
+
ax.set_title(title, fontsize=10, loc="left")
|
|
122
|
+
|
|
123
|
+
if colorbar:
|
|
124
|
+
cb = plt.colorbar(mesh, ax=ax, fraction=0.046, pad=0.04)
|
|
125
|
+
lab = {"z": "Z(u, v)", "phi": "φ(u, v)", "phi_rel": "φ / φ_max",
|
|
126
|
+
"raw": "Cₙ(u, v) − uv"}[which]
|
|
127
|
+
if absolute:
|
|
128
|
+
lab = f"|{lab}|"
|
|
129
|
+
cb.set_label(lab, fontsize=9)
|
|
130
|
+
cb.ax.tick_params(labelsize=8, length=2)
|
|
131
|
+
cb.outline.set_edgecolor("#bbb")
|
|
132
|
+
if which == "z" and dm.critical_value is not None:
|
|
133
|
+
c = dm.critical_value
|
|
134
|
+
ticks = [-c, c] if not absolute else [c]
|
|
135
|
+
for t in ticks:
|
|
136
|
+
cb.ax.axhline(t, color="#1a1a19", lw=1.0)
|
|
137
|
+
return ax
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def rank_scatter(x, y, ax=None, *, s=2, alpha=0.25, xlabel="u", ylabel="v", max_points=20000, rng=0):
|
|
141
|
+
"""Scatter of pseudo-observations (F_n(X), F_n(Y)): the empirical copula sample."""
|
|
142
|
+
from .core import pseudo_obs
|
|
143
|
+
|
|
144
|
+
x = np.asarray(x, float)
|
|
145
|
+
y = np.asarray(y, float)
|
|
146
|
+
ok = np.isfinite(x) & np.isfinite(y)
|
|
147
|
+
U, V = pseudo_obs(x[ok]), pseudo_obs(y[ok])
|
|
148
|
+
if U.size > max_points:
|
|
149
|
+
idx = np.random.default_rng(rng).choice(U.size, max_points, replace=False)
|
|
150
|
+
U, V = U[idx], V[idx]
|
|
151
|
+
if ax is None:
|
|
152
|
+
_, ax = plt.subplots(figsize=(4.4, 4.4))
|
|
153
|
+
ax.scatter(U, V, s=s, alpha=alpha, color="#2a78d6", linewidths=0)
|
|
154
|
+
ax.set_xlim(0, 1)
|
|
155
|
+
ax.set_ylim(0, 1)
|
|
156
|
+
ax.set_aspect("equal")
|
|
157
|
+
ax.set_xlabel(xlabel, fontsize=9)
|
|
158
|
+
ax.set_ylabel(ylabel, fontsize=9)
|
|
159
|
+
ax.tick_params(length=2, labelsize=8, colors="#555")
|
|
160
|
+
for sp in ax.spines.values():
|
|
161
|
+
sp.set_color("#bbb")
|
|
162
|
+
return ax
|
depmaps/simulate.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Copula samplers used in examples and tests (uniform margins)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
from scipy import stats
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def gaussian_copula(n: int, rho: float, rng=None) -> np.ndarray:
|
|
9
|
+
rng = np.random.default_rng(rng)
|
|
10
|
+
L = np.linalg.cholesky(np.array([[1.0, rho], [rho, 1.0]]))
|
|
11
|
+
z = rng.standard_normal((n, 2)) @ L.T
|
|
12
|
+
return stats.norm.cdf(z)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def t_copula(n: int, rho: float, df: float, rng=None) -> np.ndarray:
|
|
16
|
+
rng = np.random.default_rng(rng)
|
|
17
|
+
L = np.linalg.cholesky(np.array([[1.0, rho], [rho, 1.0]]))
|
|
18
|
+
z = rng.standard_normal((n, 2)) @ L.T
|
|
19
|
+
w = np.sqrt(df / rng.chisquare(df, size=n))[:, None]
|
|
20
|
+
return stats.t.cdf(z * w, df)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def clayton_copula(n: int, theta: float, rng=None) -> np.ndarray:
|
|
24
|
+
"""Clayton copula (lower-tail dependence), theta > 0, Marshall-Olkin sampler."""
|
|
25
|
+
rng = np.random.default_rng(rng)
|
|
26
|
+
g = rng.gamma(1.0 / theta, 1.0, size=n)
|
|
27
|
+
e = rng.exponential(size=(n, 2))
|
|
28
|
+
return (1.0 + e / g[:, None]) ** (-1.0 / theta)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def gaussian_copula_cdf(u, v, rho):
|
|
32
|
+
"""C(u, v) for the Gaussian copula."""
|
|
33
|
+
x, y = stats.norm.ppf(u), stats.norm.ppf(v)
|
|
34
|
+
return stats.multivariate_normal(mean=[0, 0], cov=[[1, rho], [rho, 1]]).cdf(np.column_stack([np.ravel(x), np.ravel(y)]))
|