plot3 0.6.8__tar.gz → 0.7.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. {plot3-0.6.8 → plot3-0.7.0}/PKG-INFO +1 -1
  2. plot3-0.7.0/plot3/__version__.py +1 -0
  3. {plot3-0.6.8 → plot3-0.7.0}/plot3/build.py +39 -16
  4. {plot3-0.6.8 → plot3-0.7.0}/plot3/encode.py +8 -4
  5. plot3-0.7.0/plot3/kde.py +124 -0
  6. {plot3-0.6.8 → plot3-0.7.0}/plot3/scales.py +13 -3
  7. {plot3-0.6.8 → plot3-0.7.0}/plot3/stat2d.py +6 -0
  8. {plot3-0.6.8 → plot3-0.7.0}/plot3.egg-info/PKG-INFO +1 -1
  9. {plot3-0.6.8 → plot3-0.7.0}/plot3.egg-info/SOURCES.txt +2 -0
  10. plot3-0.7.0/tests/test_fast_paths.py +106 -0
  11. plot3-0.6.8/plot3/__version__.py +0 -1
  12. {plot3-0.6.8 → plot3-0.7.0}/LICENSE +0 -0
  13. {plot3-0.6.8 → plot3-0.7.0}/README.md +0 -0
  14. {plot3-0.6.8 → plot3-0.7.0}/plot3/__init__.py +0 -0
  15. {plot3-0.6.8 → plot3-0.7.0}/plot3/aesexpr.py +0 -0
  16. {plot3-0.6.8 → plot3-0.7.0}/plot3/calculus.py +0 -0
  17. {plot3-0.6.8 → plot3-0.7.0}/plot3/compose.py +0 -0
  18. {plot3-0.6.8 → plot3-0.7.0}/plot3/contour.py +0 -0
  19. {plot3-0.6.8 → plot3-0.7.0}/plot3/craft.py +0 -0
  20. {plot3-0.6.8 → plot3-0.7.0}/plot3/expr.py +0 -0
  21. {plot3-0.6.8 → plot3-0.7.0}/plot3/flip.py +0 -0
  22. {plot3-0.6.8 → plot3-0.7.0}/plot3/function.py +0 -0
  23. {plot3-0.6.8 → plot3-0.7.0}/plot3/geoms.py +0 -0
  24. {plot3-0.6.8 → plot3-0.7.0}/plot3/ggplot.py +0 -0
  25. {plot3-0.6.8 → plot3-0.7.0}/plot3/io.py +0 -0
  26. {plot3-0.6.8 → plot3-0.7.0}/plot3/jupyter.py +0 -0
  27. {plot3-0.6.8 → plot3-0.7.0}/plot3/latexin.py +0 -0
  28. {plot3-0.6.8 → plot3-0.7.0}/plot3/masking.py +0 -0
  29. {plot3-0.6.8 → plot3-0.7.0}/plot3/mathtext.py +0 -0
  30. {plot3-0.6.8 → plot3-0.7.0}/plot3/payload.py +0 -0
  31. {plot3-0.6.8 → plot3-0.7.0}/plot3/remote.py +0 -0
  32. {plot3-0.6.8 → plot3-0.7.0}/plot3/scaling.py +0 -0
  33. {plot3-0.6.8 → plot3-0.7.0}/plot3/special.py +0 -0
  34. {plot3-0.6.8 → plot3-0.7.0}/plot3/static.py +0 -0
  35. {plot3-0.6.8 → plot3-0.7.0}/plot3/stats3d.py +0 -0
  36. {plot3-0.6.8 → plot3-0.7.0}/plot3/table.py +0 -0
  37. {plot3-0.6.8 → plot3-0.7.0}/plot3/themes.py +0 -0
  38. {plot3-0.6.8 → plot3-0.7.0}/plot3/viewer.py +0 -0
  39. {plot3-0.6.8 → plot3-0.7.0}/plot3.egg-info/dependency_links.txt +0 -0
  40. {plot3-0.6.8 → plot3-0.7.0}/plot3.egg-info/requires.txt +0 -0
  41. {plot3-0.6.8 → plot3-0.7.0}/plot3.egg-info/top_level.txt +0 -0
  42. {plot3-0.6.8 → plot3-0.7.0}/pyproject.toml +0 -0
  43. {plot3-0.6.8 → plot3-0.7.0}/setup.cfg +0 -0
  44. {plot3-0.6.8 → plot3-0.7.0}/tests/test_2d_density.py +0 -0
  45. {plot3-0.6.8 → plot3-0.7.0}/tests/test_3d.py +0 -0
  46. {plot3-0.6.8 → plot3-0.7.0}/tests/test_animation.py +0 -0
  47. {plot3-0.6.8 → plot3-0.7.0}/tests/test_annotate.py +0 -0
  48. {plot3-0.6.8 → plot3-0.7.0}/tests/test_api_public.py +0 -0
  49. {plot3-0.6.8 → plot3-0.7.0}/tests/test_array_backend.py +0 -0
  50. {plot3-0.6.8 → plot3-0.7.0}/tests/test_arrow.py +0 -0
  51. {plot3-0.6.8 → plot3-0.7.0}/tests/test_build_contract.py +0 -0
  52. {plot3-0.6.8 → plot3-0.7.0}/tests/test_contour.py +0 -0
  53. {plot3-0.6.8 → plot3-0.7.0}/tests/test_everyday_ggplot2.py +0 -0
  54. {plot3-0.6.8 → plot3-0.7.0}/tests/test_expr.py +0 -0
  55. {plot3-0.6.8 → plot3-0.7.0}/tests/test_facets_compose.py +0 -0
  56. {plot3-0.6.8 → plot3-0.7.0}/tests/test_geom_function.py +0 -0
  57. {plot3-0.6.8 → plot3-0.7.0}/tests/test_geoms_ds.py +0 -0
  58. {plot3-0.6.8 → plot3-0.7.0}/tests/test_ggplot2_extras2.py +0 -0
  59. {plot3-0.6.8 → plot3-0.7.0}/tests/test_ggplot2_parity.py +0 -0
  60. {plot3-0.6.8 → plot3-0.7.0}/tests/test_ggplot_extras.py +0 -0
  61. {plot3-0.6.8 → plot3-0.7.0}/tests/test_grammar.py +0 -0
  62. {plot3-0.6.8 → plot3-0.7.0}/tests/test_latex_input.py +0 -0
  63. {plot3-0.6.8 → plot3-0.7.0}/tests/test_lidar_scene.py +0 -0
  64. {plot3-0.6.8 → plot3-0.7.0}/tests/test_masking.py +0 -0
  65. {plot3-0.6.8 → plot3-0.7.0}/tests/test_math_layers.py +0 -0
  66. {plot3-0.6.8 → plot3-0.7.0}/tests/test_mathtext.py +0 -0
  67. {plot3-0.6.8 → plot3-0.7.0}/tests/test_payload.py +0 -0
  68. {plot3-0.6.8 → plot3-0.7.0}/tests/test_payload_display.py +0 -0
  69. {plot3-0.6.8 → plot3-0.7.0}/tests/test_point_cloud.py +0 -0
  70. {plot3-0.6.8 → plot3-0.7.0}/tests/test_r_oracle_parity.py +0 -0
  71. {plot3-0.6.8 → plot3-0.7.0}/tests/test_readme.py +0 -0
  72. {plot3-0.6.8 → plot3-0.7.0}/tests/test_remote_bridge.py +0 -0
  73. {plot3-0.6.8 → plot3-0.7.0}/tests/test_review_fixes.py +0 -0
  74. {plot3-0.6.8 → plot3-0.7.0}/tests/test_save_menu.py +0 -0
  75. {plot3-0.6.8 → plot3-0.7.0}/tests/test_scales_api.py +0 -0
  76. {plot3-0.6.8 → plot3-0.7.0}/tests/test_scales_themes.py +0 -0
  77. {plot3-0.6.8 → plot3-0.7.0}/tests/test_special.py +0 -0
  78. {plot3-0.6.8 → plot3-0.7.0}/tests/test_stat2d.py +0 -0
  79. {plot3-0.6.8 → plot3-0.7.0}/tests/test_static_save.py +0 -0
  80. {plot3-0.6.8 → plot3-0.7.0}/tests/test_stats_semantic.py +0 -0
  81. {plot3-0.6.8 → plot3-0.7.0}/tests/test_step3_geoms.py +0 -0
  82. {plot3-0.6.8 → plot3-0.7.0}/tests/test_table_backends.py +0 -0
  83. {plot3-0.6.8 → plot3-0.7.0}/tests/test_titles_theme.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plot3
3
- Version: 0.6.8
3
+ Version: 0.7.0
4
4
  Summary: ggplot2 grammar of graphics for Python: interactive WebGL figures and journal-ready PNG, SVG, and PDF
5
5
  Author: Rigoberto Leyva Salmeron
6
6
  License-Expression: MIT
@@ -0,0 +1 @@
1
+ __version__ = "0.7.0"
@@ -10,6 +10,7 @@ import pandas as pd
10
10
  import copy
11
11
  import os
12
12
 
13
+ from plot3.kde import EXACT_WORK, gaussian_1d
13
14
  from plot3.encode import encode_codes, encode_norm, pack_u16, pack_u32, pack_u8
14
15
  from plot3.mathtext import split_math
15
16
  from plot3.geoms import (
@@ -100,6 +101,11 @@ def _kde_1d(
100
101
  if hi <= lo:
101
102
  hi = lo + 1.0
102
103
  grid = np.linspace(lo, hi, n)
104
+ if x.size * n > EXACT_WORK:
105
+ # Large inputs: linear binning and an FFT, as R's density() does.
106
+ sums = gaussian_1d(x, lo, hi, n, bw)
107
+ if sums is not None:
108
+ return grid, sums / x.size
103
109
  # (n_grid, n_obs)
104
110
  u = (grid[:, None] - x[None, :]) / bw
105
111
  dens = np.exp(-0.5 * u * u).sum(axis=1) / (
@@ -1522,6 +1528,26 @@ def _rug_series(rug, g):
1522
1528
  return out
1523
1529
 
1524
1530
 
1531
+ def _remap_codes(codes, remap: dict, default) -> np.ndarray:
1532
+ """``remap.get(int(c), default)`` for every code, as one table lookup."""
1533
+ c = np.asarray(codes)
1534
+ if c.dtype.kind not in "biuf" or (c.dtype.kind == "f" and not np.isfinite(c).all()):
1535
+ # Same per-value rule, errors included (int() of NaN raises).
1536
+ return np.array([remap.get(int(x), default) for x in c.ravel()]).reshape(c.shape)
1537
+ ints = c.astype(np.int64)
1538
+ out = np.full(ints.shape, default)
1539
+ if remap and ints.size:
1540
+ keys = np.fromiter(remap.keys(), dtype=np.int64, count=len(remap))
1541
+ values = np.array(list(remap.values()))
1542
+ order = np.argsort(keys)
1543
+ keys, values = keys[order], values[order]
1544
+ at = np.clip(np.searchsorted(keys, ints), 0, len(keys) - 1)
1545
+ hit = keys[at] == ints
1546
+ out = out.astype(np.result_type(out, values))
1547
+ out[hit] = values[at[hit]]
1548
+ return out
1549
+
1550
+
1525
1551
  def _rug_positions(scale, series) -> np.ndarray:
1526
1552
  """Values in the scale's own units; NaN where they have no place."""
1527
1553
  kind, pos, cats = col_values(series)
@@ -1531,6 +1557,13 @@ def _rug_positions(scale, series) -> np.ndarray:
1531
1557
  lookup = np.array([index.get(c, np.nan) for c in cats] + [np.nan], dtype=np.float64)
1532
1558
  codes = np.where(np.isfinite(pos), pos, len(cats)).astype(np.int64)
1533
1559
  return lookup[codes]
1560
+ values = np.asarray(series)
1561
+ # float32 prints differently as a NumPy scalar than as a float.
1562
+ if values.dtype.kind in "biu" or values.dtype == np.float64:
1563
+ # One text lookup per distinct value (NaN included), not per row.
1564
+ codes, uniques = pd.factorize(values, use_na_sentinel=False)
1565
+ lookup = np.array([index.get(str(u), np.nan) for u in uniques.tolist()], dtype=np.float64)
1566
+ return lookup[codes] if len(codes) else np.array([], dtype=np.float64)
1534
1567
  return np.array([index.get(str(v), np.nan) for v in series], dtype=np.float64)
1535
1568
  if kind == "cat":
1536
1569
  return np.full(len(pos), np.nan)
@@ -2211,10 +2244,8 @@ def build_spec(g: ggplot) -> tuple[dict, list[tuple[str, str]]]:
2211
2244
  }
2212
2245
  out = np.full(np.size(v), np.nan, dtype=np.float64)
2213
2246
  flat = np.asarray(v, dtype=np.float64).ravel()
2214
- for i, c in enumerate(flat):
2215
- if not math.isfinite(c):
2216
- continue
2217
- out[i] = remap.get(int(c), -1)
2247
+ finite = np.isfinite(flat)
2248
+ out[finite] = _remap_codes(flat[finite], remap, -1)
2218
2249
  v = out.reshape(np.shape(v))
2219
2250
  sc.cats = merged
2220
2251
  else:
@@ -3089,9 +3120,7 @@ def build_spec(g: ggplot) -> tuple[dict, list[tuple[str, str]]]:
3089
3120
  i: color_scale[1].index(c)
3090
3121
  for i, c in enumerate(local)
3091
3122
  }
3092
- codes = np.array(
3093
- [remap.get(int(c), 0) for c in cv], dtype="<u2"
3094
- )
3123
+ codes = _remap_codes(cv, remap, 0).astype("<u2")
3095
3124
  payloads.append((pid, pack_u16(codes, g.compress)))
3096
3125
  spec_l["ocolor"] = {
3097
3126
  "id": pid, "dtype": "u16", "kind": "cat"
@@ -3121,8 +3150,7 @@ def build_spec(g: ggplot) -> tuple[dict, list[tuple[str, str]]]:
3121
3150
  # remap onto the global cat list
3122
3151
  local = vals["color"][2]
3123
3152
  remap = {i: color_scale[1].index(c) for i, c in enumerate(local)}
3124
- codes = np.array([remap.get(int(c), 0) for c in cv[order]],
3125
- dtype="<u2")
3153
+ codes = _remap_codes(cv[order], remap, 0).astype("<u2")
3126
3154
  payloads.append((pid, pack_u16(codes, g.compress)))
3127
3155
  spec_l["color"] = {"id": pid, "dtype": "u16", "kind": "cat"}
3128
3156
  else:
@@ -3140,9 +3168,7 @@ def build_spec(g: ggplot) -> tuple[dict, list[tuple[str, str]]]:
3140
3168
  if ckind == "cat":
3141
3169
  local = vals["color"][2]
3142
3170
  remap = {i: color_scale[1].index(c) for i, c in enumerate(local)}
3143
- codes = np.array(
3144
- [remap.get(int(c), 0) for c in cv[order]], dtype="<u2"
3145
- )
3171
+ codes = _remap_codes(cv[order], remap, 0).astype("<u2")
3146
3172
  payloads.append((pid, pack_u16(codes, g.compress)))
3147
3173
  spec_l["color"] = {"id": pid, "dtype": "u16", "kind": "cat"}
3148
3174
  else:
@@ -3322,10 +3348,7 @@ def build_spec(g: ggplot) -> tuple[dict, list[tuple[str, str]]]:
3322
3348
  flat = np.full(raw.shape, 65535, dtype=np.uint16)
3323
3349
  ok = np.isfinite(raw) & (raw >= 0)
3324
3350
  if ok.any():
3325
- mapped = np.array(
3326
- [remap.get(int(c), 0) for c in raw[ok]],
3327
- dtype=np.uint16,
3328
- )
3351
+ mapped = _remap_codes(raw[ok], remap, 0).astype(np.uint16)
3329
3352
  flat[np.flatnonzero(ok)] = mapped
3330
3353
  enc = encode_codes(flat, g.compress)
3331
3354
  pid = f"p{li}fc"
@@ -7,6 +7,10 @@ import gzip as _gzip
7
7
 
8
8
  import numpy as np
9
9
 
10
+ # gzip is lossless at every level. Level 1 builds large payloads several
11
+ # times faster than 6 for a few percent more bytes.
12
+ _LEVEL = 1
13
+
10
14
  def delta_u16(a: np.ndarray) -> np.ndarray:
11
15
  """Column-wise delta mod 2^16 (lossless; tiny values on ordered data)."""
12
16
  d = a.astype(np.int32)
@@ -21,7 +25,7 @@ def pack_u16(q: np.ndarray, compress: bool) -> str:
21
25
  raw = _gzip.compress(
22
26
  np.ascontiguousarray(d[:, 0]).tobytes()
23
27
  + np.ascontiguousarray(d[:, 1]).tobytes(),
24
- 6,
28
+ compresslevel=_LEVEL, mtime=0,
25
29
  )
26
30
  else:
27
31
  raw = np.ascontiguousarray(q, dtype="<u2").tobytes()
@@ -31,7 +35,7 @@ def pack_u16(q: np.ndarray, compress: bool) -> str:
31
35
  def pack_f32(v: np.ndarray, compress: bool) -> str:
32
36
  raw = np.ascontiguousarray(v, dtype="<f4").tobytes()
33
37
  if compress:
34
- raw = _gzip.compress(raw, 6)
38
+ raw = _gzip.compress(raw, _LEVEL, mtime=0)
35
39
  return base64.b64encode(raw).decode("ascii")
36
40
 
37
41
 
@@ -55,7 +59,7 @@ def pack_u8(values: np.ndarray, compress: bool) -> str:
55
59
  """uint8 array -> base64 (optional gzip; no delta)."""
56
60
  raw = np.ascontiguousarray(values, dtype=np.uint8).tobytes()
57
61
  if compress:
58
- raw = _gzip.compress(raw, 6)
62
+ raw = _gzip.compress(raw, _LEVEL, mtime=0)
59
63
  return base64.b64encode(raw).decode("ascii")
60
64
 
61
65
 
@@ -63,6 +67,6 @@ def pack_u32(values: np.ndarray, compress: bool) -> str:
63
67
  """uint32 array -> base64 (optional gzip; no delta)."""
64
68
  raw = np.ascontiguousarray(values, dtype="<u4").tobytes()
65
69
  if compress:
66
- raw = _gzip.compress(raw, 6)
70
+ raw = _gzip.compress(raw, _LEVEL, mtime=0)
67
71
  return base64.b64encode(raw).decode("ascii")
68
72
 
@@ -0,0 +1,124 @@
1
+ """Binned Gaussian kernel density sums (linear binning + FFT convolution).
2
+
3
+ R's ``density()`` bins too. Values are spread onto a fine grid (at least
4
+ four points per output interval, at most 1/20 of a bandwidth apart in
5
+ 1D and 1/4 in 2D),
6
+ convolved with the kernel by FFT, and read back at the output points. The
7
+ relative error is far below what a plot shows. Values more than
8
+ ``_REACH`` bandwidths past the output range add nothing in float64 and are
9
+ left out. The callers keep the exact sum for small inputs, and these
10
+ helpers return None when the fine grid would be too large.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import numpy as np
16
+
17
+ # Below this many (observations x output points) the exact sum is cheap.
18
+ EXACT_WORK = 2_000_000
19
+ # The fine spacing is at most this fraction of the bandwidth (1D, 2D), and
20
+ # the fine grid has at least four points per output interval. Linear
21
+ # binning's error grows with the square of that fraction.
22
+ _MAX_STEP_1D = 0.05
23
+ _MAX_STEP_2D = 0.25
24
+ _MIN_FINE = 4
25
+ # Past this many bandwidths a kernel underflows to zero in float64.
26
+ _REACH = 40.0
27
+ # Largest fine grid (points, or cells in 2D) worth binning onto.
28
+ _MAX_CELLS = 1 << 22
29
+
30
+
31
+ def _fine_axis(values: np.ndarray, lo: float, hi: float, n: int, h: float, max_step: float):
32
+ """``(origin, step, size, first, fine)`` of a fine grid through the output points.
33
+
34
+ The output point ``k`` sits at fine index ``first + k * fine``. The grid
35
+ covers every value within ``_REACH`` bandwidths of the output range;
36
+ values farther out add nothing to the sums.
37
+ """
38
+ if n < 2 or not hi > lo or not h > 0 or not values.size:
39
+ return None
40
+ fine = max(_MIN_FINE, int(np.ceil((hi - lo) / ((n - 1) * max_step * h))))
41
+ step = (hi - lo) / ((n - 1) * fine)
42
+ low = max(float(values.min()), lo - _REACH * h)
43
+ high = min(float(values.max()), hi + _REACH * h)
44
+ below = max(0, int(np.ceil((lo - low) / step)))
45
+ above = max(0, int(np.ceil((high - hi) / step)))
46
+ size = (n - 1) * fine + 1 + below + above
47
+ if size > _MAX_CELLS:
48
+ return None
49
+ return lo - below * step, step, size, below, fine
50
+
51
+
52
+ def _within(values: np.ndarray, origin: float, step: float, size: int) -> np.ndarray:
53
+ return (values >= origin) & (values <= origin + (size - 1) * step)
54
+
55
+
56
+ def _linear_bins(values: np.ndarray, origin: float, step: float, size: int):
57
+ """Indices and weights of the two fine points around each value."""
58
+ pos = (values - origin) / step
59
+ left = np.clip(np.floor(pos).astype(np.int64), 0, size - 2)
60
+ frac = np.clip(pos - left, 0.0, 1.0)
61
+ return left, frac
62
+
63
+
64
+ def _kernel_fft(step: float, size: int, h: float, length: int) -> np.ndarray:
65
+ offsets = np.arange(-(size - 1), size) * step
66
+ kernel = np.exp(-0.5 * (offsets / h) ** 2) / (h * np.sqrt(2.0 * np.pi))
67
+ return np.fft.rfft(kernel, length)
68
+
69
+
70
+ def _fft_length(size: int) -> int:
71
+ return 1 << int(np.ceil(np.log2(3 * size - 2)))
72
+
73
+
74
+ def _convolve(weights: np.ndarray, step: float, size: int, h: float, axis: int) -> np.ndarray:
75
+ """``sum_i w[i] K((j - i) step)`` for every fine index ``j`` along *axis*."""
76
+ length = _fft_length(size)
77
+ spectrum = np.fft.rfft(weights, length, axis=axis)
78
+ shape = [1] * weights.ndim
79
+ shape[axis] = -1
80
+ full = np.fft.irfft(spectrum * _kernel_fft(step, size, h, length).reshape(shape), length, axis=axis)
81
+ return np.take(full, np.arange(size - 1, 2 * size - 1), axis=axis)
82
+
83
+
84
+ def gaussian_1d(x: np.ndarray, lo: float, hi: float, n: int, h: float) -> np.ndarray | None:
85
+ """``sum_i phi((g - x_i) / h) / h`` at ``linspace(lo, hi, n)``, or None."""
86
+ axis = _fine_axis(x, lo, hi, n, h, _MAX_STEP_1D)
87
+ if axis is None:
88
+ return None
89
+ origin, step, size, first, fine = axis
90
+ x = x[_within(x, origin, step, size)]
91
+ left, frac = _linear_bins(x, origin, step, size)
92
+ weights = np.bincount(left, 1.0 - frac, size) + np.bincount(left + 1, frac, size)
93
+ sums = _convolve(weights, step, size, h, axis=0)
94
+ return np.maximum(sums[first:first + (n - 1) * fine + 1:fine], 0.0)
95
+
96
+
97
+ def gaussian_2d(
98
+ xs: np.ndarray, ys: np.ndarray, x_lims: tuple[float, float], y_lims: tuple[float, float],
99
+ n: int, hx: float, hy: float,
100
+ ) -> np.ndarray | None:
101
+ """``sum_i phi_x(gx - x_i) phi_y(gy - y_i)`` as an ``(n, n)`` (y, x) grid, or None."""
102
+ ax = _fine_axis(xs, x_lims[0], x_lims[1], n, hx, _MAX_STEP_2D)
103
+ ay = _fine_axis(ys, y_lims[0], y_lims[1], n, hy, _MAX_STEP_2D)
104
+ if ax is None or ay is None:
105
+ return None
106
+ (x_origin, x_step, x_size, x_first, x_fine), (y_origin, y_step, y_size, y_first, y_fine) = ax, ay
107
+ # Two FFT passes over the fine grid must beat n exponentials per value
108
+ # on each axis; a grid stretched over wide limits does not.
109
+ if x_size * y_size > _MAX_CELLS or x_size * y_size * 16 > xs.size * n:
110
+ return None
111
+ keep = _within(xs, x_origin, x_step, x_size) & _within(ys, y_origin, y_step, y_size)
112
+ xs, ys = xs[keep], ys[keep]
113
+ xl, xf = _linear_bins(xs, x_origin, x_step, x_size)
114
+ yl, yf = _linear_bins(ys, y_origin, y_step, y_size)
115
+ cells = y_size * x_size
116
+ weights = np.zeros(cells)
117
+ for dy, wy in ((0, 1.0 - yf), (1, yf)):
118
+ for dx, wx in ((0, 1.0 - xf), (1, xf)):
119
+ weights += np.bincount((yl + dy) * x_size + xl + dx, wy * wx, cells)
120
+ weights = weights.reshape(y_size, x_size)
121
+ sums = _convolve(_convolve(weights, x_step, x_size, hx, axis=1), y_step, y_size, hy, axis=0)
122
+ rows = slice(y_first, y_first + (n - 1) * y_fine + 1, y_fine)
123
+ cols = slice(x_first, x_first + (n - 1) * x_fine + 1, x_fine)
124
+ return np.maximum(sums[rows, cols], 0.0)
@@ -288,9 +288,19 @@ def ordered_levels(values, *, keep_order: bool = False) -> list:
288
288
  "10" before "4"); anything else sorts as text.
289
289
  """
290
290
  values = list(values)
291
- present = [v for v in values if not _is_na(v)]
292
- missing = len(present) != len(values)
293
- distinct = list(dict.fromkeys(present))
291
+ try:
292
+ # Drop repeats first, then test the distinct values for missing:
293
+ # one isna() call per level instead of one per row.
294
+ keys = list(dict.fromkeys(values))
295
+ except TypeError: # an NA whose comparison has no truth value
296
+ keys = None
297
+ if keys is None:
298
+ present = [v for v in values if not _is_na(v)]
299
+ missing = len(present) != len(values)
300
+ distinct = list(dict.fromkeys(present))
301
+ else:
302
+ distinct = [v for v in keys if not _is_na(v)]
303
+ missing = len(distinct) != len(keys)
294
304
  if not keep_order:
295
305
  if all(isinstance(v, (bool, np.bool_)) for v in distinct):
296
306
  distinct.sort(key=bool)
@@ -19,6 +19,7 @@ import numpy as np
19
19
  import pandas as pd
20
20
 
21
21
  from plot3.geoms import _Geom, aes
22
+ from plot3.kde import EXACT_WORK, gaussian_2d
22
23
  from plot3.scales import col_values, ordered_levels, resolution
23
24
  from plot3.special import qt
24
25
  from plot3.table import has_column, materialize_columns
@@ -1334,6 +1335,11 @@ def _kde2d(xs, ys, n, lims=None, h=None):
1334
1335
  y0, y1 = (ys.min(), ys.max()) if lims is None else lims[2:]
1335
1336
  gx, gy = np.linspace(x0, x1, n), np.linspace(y0, y1, n)
1336
1337
  hx, hy = (_bandwidth(xs), _bandwidth(ys)) if h is None else (float(h[0]) / 4, float(h[1]) / 4)
1338
+ if xs.size * n > EXACT_WORK:
1339
+ # Large inputs: bilinear binning and FFT convolution.
1340
+ sums = gaussian_2d(xs, ys, (float(x0), float(x1)), (float(y0), float(y1)), n, hx, hy)
1341
+ if sums is not None:
1342
+ return gx, gy, sums / xs.size
1337
1343
  kx = np.exp(-0.5 * ((gx[:, None] - xs[None, :]) / hx) ** 2) / (hx * math.sqrt(2 * math.pi))
1338
1344
  ky = np.exp(-0.5 * ((gy[:, None] - ys[None, :]) / hy) ** 2) / (hy * math.sqrt(2 * math.pi))
1339
1345
  z = (ky @ kx.T) / xs.size # (ny, nx), the shape the contour code takes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plot3
3
- Version: 0.6.8
3
+ Version: 0.7.0
4
4
  Summary: ggplot2 grammar of graphics for Python: interactive WebGL figures and journal-ready PNG, SVG, and PDF
5
5
  Author: Rigoberto Leyva Salmeron
6
6
  License-Expression: MIT
@@ -17,6 +17,7 @@ plot3/geoms.py
17
17
  plot3/ggplot.py
18
18
  plot3/io.py
19
19
  plot3/jupyter.py
20
+ plot3/kde.py
20
21
  plot3/latexin.py
21
22
  plot3/masking.py
22
23
  plot3/mathtext.py
@@ -48,6 +49,7 @@ tests/test_contour.py
48
49
  tests/test_everyday_ggplot2.py
49
50
  tests/test_expr.py
50
51
  tests/test_facets_compose.py
52
+ tests/test_fast_paths.py
51
53
  tests/test_geom_function.py
52
54
  tests/test_geoms_ds.py
53
55
  tests/test_ggplot2_extras2.py
@@ -0,0 +1,106 @@
1
+ """Vectorised level, code and density paths match the per-value rules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+
7
+ import numpy as np
8
+ import pandas as pd
9
+ import pytest
10
+
11
+ import plot3.build as build
12
+ import plot3.stat2d as stat2d
13
+ from plot3.build import _kde_1d, _remap_codes, _rug_positions
14
+ from plot3.scales import Scale, _is_na, ordered_levels
15
+ from plot3.stat2d import _kde2d
16
+
17
+
18
+ def _levels_per_value(values, keep_order=False):
19
+ """ordered_levels as it was: test every row for missing, then dedupe."""
20
+ values = list(values)
21
+ present = [v for v in values if not _is_na(v)]
22
+ distinct = list(dict.fromkeys(present))
23
+ out = ordered_levels(distinct, keep_order=keep_order)
24
+ return out + ([None] if len(present) != len(values) else [])
25
+
26
+
27
+ @pytest.mark.parametrize("values", [
28
+ ["Adelie", "Gentoo", None, "Adelie", "Chinstrap", float("nan"), "Gentoo"] * 500,
29
+ [4.0, 6.0, np.nan, 8.0, 10.0, 6.0] * 500,
30
+ [True, False, None, True] * 500,
31
+ [3, 1.0, 1, 2, np.int64(3)] * 500,
32
+ ["Biscoe", pd.NA, "Dream", pd.NA, pd.NaT, "Biscoe"] * 500,
33
+ [float("nan")] * 50 + ["Torgersen"],
34
+ [],
35
+ ])
36
+ @pytest.mark.parametrize("keep_order", [False, True])
37
+ def test_ordered_levels_matches_the_per_row_rule(values, keep_order):
38
+ assert ordered_levels(values, keep_order=keep_order) == _levels_per_value(values, keep_order)
39
+
40
+
41
+ def test_remap_codes_matches_the_dict_rule():
42
+ rng = np.random.default_rng(0)
43
+ remap = {0: 2, 1: 0, 3: 1}
44
+ for codes in (rng.integers(-1, 5, 1000).astype(float), rng.integers(0, 4, 1000), np.array([], dtype=float)):
45
+ expected = np.array([remap.get(int(c), 7) for c in codes])
46
+ assert np.array_equal(_remap_codes(codes, remap, 7), expected)
47
+ assert np.array_equal(_remap_codes(np.array([1.9, -0.5]), remap, 7), [0, 2])
48
+ assert np.array_equal(_remap_codes(np.array([2.0]), {}, 0), [0])
49
+ with pytest.raises(ValueError):
50
+ _remap_codes(np.array([0.0, np.nan]), remap, 0)
51
+
52
+
53
+ @pytest.mark.parametrize("series", [
54
+ pd.Series([4.0, 6.0, np.nan, 8.0, 4.0]),
55
+ pd.Series([4, 6, 8, 6]),
56
+ pd.Series([True, False, True]),
57
+ pd.Series(np.array([4.0, 6.5], dtype=np.float32)),
58
+ ])
59
+ def test_rug_positions_on_a_discrete_scale_match_text_lookup(series):
60
+ scale = Scale("cat")
61
+ scale.cats = ["4", "6", "8", "True", "4.0", "6.0", "6.5", "nan"]
62
+ index = {c: i for i, c in enumerate(scale.cats)}
63
+ expected = np.array([index.get(str(v), np.nan) for v in series], dtype=np.float64)
64
+ np.testing.assert_array_equal(_rug_positions(scale, series), expected)
65
+
66
+
67
+ def _exact(module, fn, *args, **kwargs):
68
+ saved = module.EXACT_WORK
69
+ module.EXACT_WORK = math.inf
70
+ try:
71
+ return fn(*args, **kwargs)
72
+ finally:
73
+ module.EXACT_WORK = saved
74
+
75
+
76
+ @pytest.mark.parametrize("values, adjust", [
77
+ (np.random.default_rng(1).normal(4200, 800, 20_000), 1.0),
78
+ (np.random.default_rng(2).lognormal(0, 1.5, 20_000), 1.0),
79
+ (np.random.default_rng(3).normal(17, 2, 20_000), 0.2),
80
+ (np.r_[np.random.default_rng(4).normal(0, 1, 20_000), [1e6]], 1.0),
81
+ (np.random.default_rng(5).integers(2007, 2010, 20_000).astype(float), 1.0),
82
+ ])
83
+ def test_binned_density_matches_the_exact_sum(values, adjust):
84
+ grid, dens = _kde_1d(values, adjust=adjust)
85
+ exact_grid, exact = _exact(build, _kde_1d, values, adjust=adjust)
86
+ np.testing.assert_array_equal(grid, exact_grid)
87
+ assert np.max(np.abs(dens - exact)) <= 2e-4 * exact.max()
88
+
89
+
90
+ def test_small_densities_keep_the_exact_sum():
91
+ values = np.random.default_rng(6).normal(44, 5, 300)
92
+ np.testing.assert_array_equal(_kde_1d(values)[1], _exact(build, _kde_1d, values)[1])
93
+ xs, ys = np.random.default_rng(7).normal(0, 1, (2, 300))
94
+ np.testing.assert_array_equal(_kde2d(xs, ys, 50)[2], _exact(stat2d, _kde2d, xs, ys, 50)[2])
95
+
96
+
97
+ @pytest.mark.parametrize("lims, h", [(None, None), (None, (2.0, 1.0)), ((-1, 1, -0.5, 0.5), None)])
98
+ def test_binned_2d_density_matches_the_exact_sum(lims, h):
99
+ rng = np.random.default_rng(8)
100
+ xs, ys = rng.normal(44, 5, 60_000), rng.exponential(2, 60_000)
101
+ gx, gy, z = _kde2d(xs, ys, 100, lims, h)
102
+ ex, ey, exact = _exact(stat2d, _kde2d, xs, ys, 100, lims, h)
103
+ np.testing.assert_array_equal(gx, ex)
104
+ np.testing.assert_array_equal(gy, ey)
105
+ # A quarter-bandwidth grid: the error stays under 0.2% of the peak.
106
+ assert np.max(np.abs(z - exact)) <= 2e-3 * exact.max()
@@ -1 +0,0 @@
1
- __version__ = "0.6.8"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes