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.
@@ -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
+ ![Dependence map of simulated hourly temperature and power price](https://raw.githubusercontent.com/Brainologist/dependence-maps/main/docs/images/example4_u_shape.png)
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
+ ![Student t copula with zero correlation](https://raw.githubusercontent.com/Brainologist/dependence-maps/main/docs/images/quickstart.png)
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
+ ![Gaussian copula: raw and standardized maps](https://raw.githubusercontent.com/Brainologist/dependence-maps/main/docs/images/example1_gaussian.png)
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
+ ![Student t copula](https://raw.githubusercontent.com/Brainologist/dependence-maps/main/docs/images/example2_t_tail_dependence.png)
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
+ ![Independence](https://raw.githubusercontent.com/Brainologist/dependence-maps/main/docs/images/example3_independence.png)
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
+ ![Clayton copula](https://raw.githubusercontent.com/Brainologist/dependence-maps/main/docs/images/example5_lower_tail.png)
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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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)]))