lessPython 0.1.0__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.
- lessPy/ANOVA.py +680 -0
- lessPy/Chart.py +1055 -0
- lessPy/Correlation.py +236 -0
- lessPy/Flows.py +116 -0
- lessPy/Logit.py +615 -0
- lessPy/Prop_test.py +267 -0
- lessPy/Regression.py +1491 -0
- lessPy/VariableLabels.py +119 -0
- lessPy/X.py +426 -0
- lessPy/XY.py +2007 -0
- lessPy/__init__.py +60 -0
- lessPy/anova_rmd.py +227 -0
- lessPy/bc_plotly.py +575 -0
- lessPy/bubble_plotly.py +470 -0
- lessPy/corCFA.py +316 -0
- lessPy/corEFA.py +220 -0
- lessPy/corPrint.py +45 -0
- lessPy/corProp.py +73 -0
- lessPy/corRead.py +48 -0
- lessPy/corReflect.py +72 -0
- lessPy/corReorder.py +161 -0
- lessPy/corScree.py +87 -0
- lessPy/data/Anova_1way.csv +25 -0
- lessPy/data/Anova_2way.csv +49 -0
- lessPy/data/Anova_rb.csv +8 -0
- lessPy/data/Anova_rbf.csv +49 -0
- lessPy/data/Anova_sp.csv +57 -0
- lessPy/data/BodyMeas.csv +341 -0
- lessPy/data/Cars93.csv +94 -0
- lessPy/data/Employee.csv +38 -0
- lessPy/data/Employee_lbl.csv +9 -0
- lessPy/data/FreqTable99.csv +5 -0
- lessPy/data/Jackets.csv +1026 -0
- lessPy/data/Learn.csv +35 -0
- lessPy/data/Mach4.csv +352 -0
- lessPy/data/Mach4_lbl.csv +21 -0
- lessPy/data/Reading.csv +101 -0
- lessPy/data/StockPrice.csv +1489 -0
- lessPy/data/WeightLoss.csv +11 -0
- lessPy/datasets.py +46 -0
- lessPy/date_infer.py +112 -0
- lessPy/details.py +314 -0
- lessPy/dn_plotly.py +495 -0
- lessPy/dot_plotly.py +385 -0
- lessPy/freq_poly_plotly.py +324 -0
- lessPy/getColors.py +399 -0
- lessPy/hier_plotly.py +352 -0
- lessPy/hs_plotly.py +395 -0
- lessPy/logit_rmd.py +410 -0
- lessPy/order_by.py +94 -0
- lessPy/pie_plotly.py +292 -0
- lessPy/pivot.py +158 -0
- lessPy/plotly_utils.py +787 -0
- lessPy/plt_add.py +129 -0
- lessPy/plt_contour.py +192 -0
- lessPy/plt_contour_facet.py +194 -0
- lessPy/plt_forecast.py +677 -0
- lessPy/plt_mat_plotly.py +201 -0
- lessPy/plt_plotly.py +216 -0
- lessPy/plt_smooth.py +170 -0
- lessPy/plt_time.py +143 -0
- lessPy/prob_norm.py +111 -0
- lessPy/prob_tcut.py +131 -0
- lessPy/prob_znorm.py +110 -0
- lessPy/radar_plotly.py +201 -0
- lessPy/reg_rmd.py +754 -0
- lessPy/rename.py +33 -0
- lessPy/reshape.py +95 -0
- lessPy/showColors.py +130 -0
- lessPy/simCImean.py +165 -0
- lessPy/simCLT.py +265 -0
- lessPy/simFlips.py +104 -0
- lessPy/simMeans.py +146 -0
- lessPy/stats_out.py +189 -0
- lessPy/ttest.py +641 -0
- lessPy/utils.py +235 -0
- lessPy/vbs_plotly.py +545 -0
- lesspython-0.1.0.dist-info/METADATA +93 -0
- lesspython-0.1.0.dist-info/RECORD +82 -0
- lesspython-0.1.0.dist-info/WHEEL +5 -0
- lesspython-0.1.0.dist-info/licenses/LICENSE +338 -0
- lesspython-0.1.0.dist-info/top_level.txt +1 -0
lessPy/XY.py
ADDED
|
@@ -0,0 +1,2007 @@
|
|
|
1
|
+
# XY.py — analog of XY.R (pipeline; scatter / time-series core)
|
|
2
|
+
#
|
|
3
|
+
# XY(): the two-variable analytic view — the relationship between
|
|
4
|
+
# two NUMERICAL variables. Categorical variables belong to
|
|
5
|
+
# Chart(). As with Chart() and X(), the pipeline is ported, not
|
|
6
|
+
# the lines: XY.R's NSE, legacy parameters, base-R/lattice paths,
|
|
7
|
+
# and PDF device code have no Python counterpart.
|
|
8
|
+
#
|
|
9
|
+
# Ported in this increment: the scatterplot — points, by= groups,
|
|
10
|
+
# the least-squares fit family (lm/ls, null, exp, quad, power,
|
|
11
|
+
# log) and loess, both with SE bands, data ellipses — the date-x
|
|
12
|
+
# time-series line display, forecasting (plt_forecast.py:
|
|
13
|
+
# ts_source="fable" on statsmodels ETS/TSLM, and "classic"
|
|
14
|
+
# Holt-Winters and seasonal regression), facet= panels
|
|
15
|
+
# (numeric x), accompanying statistics (stats_out.py, quiet=
|
|
16
|
+
# to suppress), outlier/MD flagging (MD_cut/out_cut, with
|
|
17
|
+
# the dashed outlier-removed fit line), form="contour"
|
|
18
|
+
# (plt_contour.py: filled contours of the 2-D kernel density),
|
|
19
|
+
# and form="smooth" (plt_smooth.py: the smoothScatter() density
|
|
20
|
+
# raster with low-density points overplotted), and jitter_x/
|
|
21
|
+
# jitter_y (display only, auto for a discrete axis; the fit,
|
|
22
|
+
# ellipse, and statistics use the original data, as in R), and
|
|
23
|
+
# enhance= (the enhanced-scatterplot bundle: ellipse, MD
|
|
24
|
+
# outliers, lm fit, mean crosshair), facet= for a date x
|
|
25
|
+
# (_ts_facet: time-series panels; by= with a faceted time
|
|
26
|
+
# series is not yet ported), and the ts_ family: ts_unit/
|
|
27
|
+
# ts_agg aggregation (plt_time.py), ts_stack stacked series,
|
|
28
|
+
# and ts_area_fill/ts_area_split area fills.
|
|
29
|
+
#
|
|
30
|
+
# facet= orthogonality (July 2026, ~ the R facet unification):
|
|
31
|
+
# facet= takes a column name, a list of two names (a row x
|
|
32
|
+
# column grid, rows the second variable), or an aligned
|
|
33
|
+
# Series/array of computed values (the analog of R's facet
|
|
34
|
+
# expression); n_row/n_col lay out the panels. Faceted
|
|
35
|
+
# contour/smooth render through plt_contour_facet.py
|
|
36
|
+
# (~ .plt.contour.facet, shared KDE grid and bandwidth). A
|
|
37
|
+
# vector of x (or y) names overlays series on one panel
|
|
38
|
+
# (_series_overlay ~ the .plt.main overlay) and facet= panels
|
|
39
|
+
# the overlay (_facet_series ~ .plt.facet.series); by= with a
|
|
40
|
+
# vector errors, redirecting to facet=. The same numeric
|
|
41
|
+
# vector for both x and y draws a scatterplot matrix, via the
|
|
42
|
+
# shared scatter_matrix helper (plt_mat_plotly). The faceted
|
|
43
|
+
# scatter prints a summary table per grouping variable
|
|
44
|
+
# (facet_summary ~ .vbs_summary_table).
|
|
45
|
+
# The categorical-x delegations are not ported (bubble and
|
|
46
|
+
# Cleveland displays are internal-only; categorical data
|
|
47
|
+
# belong to Chart()). R's form="hexbin" is dropped by design:
|
|
48
|
+
# plotly has no hexbin trace.
|
|
49
|
+
|
|
50
|
+
import math
|
|
51
|
+
|
|
52
|
+
import numpy as np
|
|
53
|
+
import pandas as pd
|
|
54
|
+
import plotly.graph_objects as go
|
|
55
|
+
from scipy import stats as sps
|
|
56
|
+
|
|
57
|
+
from .plt_add import plt_add
|
|
58
|
+
from .plt_contour import plt_contour
|
|
59
|
+
from .plt_contour_facet import plt_contour_facet
|
|
60
|
+
from .date_infer import date_infer
|
|
61
|
+
from .plt_forecast import plt_forecast
|
|
62
|
+
from .plt_plotly import plt_plotly
|
|
63
|
+
from .plt_smooth import plt_smooth
|
|
64
|
+
from .plt_time import plt_time
|
|
65
|
+
from .plt_mat_plotly import scatter_matrix
|
|
66
|
+
from .stats_out import (
|
|
67
|
+
facet_summary, md_outliers, resolve_quiet, xy_stats)
|
|
68
|
+
from .plotly_utils import (
|
|
69
|
+
BASE_COLORS, as_plotly_color, axis_format, axis_num,
|
|
70
|
+
facet_fig, facet_panels, finish_facet,
|
|
71
|
+
get_tick_fmt, legend_style, make_trans, plot_border,
|
|
72
|
+
plotly_style, square_layout, sym_at, to_hex, x_grid,
|
|
73
|
+
font_scaled)
|
|
74
|
+
from .utils import (
|
|
75
|
+
category_order, get_column, get_option, pretty, resolve_facet,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
_FORMS = ("scatter", "smooth", "contour")
|
|
79
|
+
_FITS = ("off", "loess", "lm", "ls", "null", "exp", "quad",
|
|
80
|
+
"power", "log")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _plt_fit(xv, yv, fit, fit_power):
|
|
84
|
+
"""Fitted curve at the sorted x values, via least squares on
|
|
85
|
+
the (possibly transformed) data. R analog: .plt.fit()"""
|
|
86
|
+
od = np.argsort(xv, kind="stable")
|
|
87
|
+
xs, ys = xv[od], yv[od]
|
|
88
|
+
if fit == "null":
|
|
89
|
+
return xs, ys, np.full(len(xs), ys.mean())
|
|
90
|
+
|
|
91
|
+
p = float(fit_power)
|
|
92
|
+
with np.errstate(invalid="ignore", divide="ignore"):
|
|
93
|
+
if fit == "lm":
|
|
94
|
+
z = ys
|
|
95
|
+
elif fit == "quad":
|
|
96
|
+
z, p = np.sqrt(ys), 2.0 # sqrt(y) ~ x, then square
|
|
97
|
+
elif fit == "power":
|
|
98
|
+
z = ys ** (1.0 / p)
|
|
99
|
+
elif fit == "exp":
|
|
100
|
+
if (ys <= 0).all():
|
|
101
|
+
raise ValueError("All values of y are "
|
|
102
|
+
"non-positive, cannot take log(y)")
|
|
103
|
+
z = np.log(ys if p == 1 else ys ** (1.0 / p))
|
|
104
|
+
elif fit == "log":
|
|
105
|
+
z = np.exp(ys if p == 1 else ys ** (1.0 / p))
|
|
106
|
+
if np.isinf(z).any():
|
|
107
|
+
raise ValueError("Some values of y too large for "
|
|
108
|
+
"exp(y). Rescale.")
|
|
109
|
+
|
|
110
|
+
ok = np.isfinite(z) # log/sqrt of negatives drop
|
|
111
|
+
if ok.sum() < 2:
|
|
112
|
+
raise ValueError(f'fit="{fit}": fewer than 2 usable '
|
|
113
|
+
"values after transformation")
|
|
114
|
+
b1, b0 = np.polyfit(xs[ok], z[ok], 1)
|
|
115
|
+
lin = b0 + b1 * xs
|
|
116
|
+
with np.errstate(invalid="ignore"):
|
|
117
|
+
if fit == "lm":
|
|
118
|
+
f = lin
|
|
119
|
+
elif fit in ("quad", "power"):
|
|
120
|
+
f = lin ** p
|
|
121
|
+
elif fit == "exp":
|
|
122
|
+
f = np.exp(lin)
|
|
123
|
+
else: # log: y = log(b0 + b1*x)
|
|
124
|
+
f = np.log(lin)
|
|
125
|
+
return xs, ys, f
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
_FIT_NEW_OK = ("lm", "quad", "power", "exp", "log")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _fit_new_values(xv, yv, fit, fit_power, x_new):
|
|
132
|
+
"""Predict y at new x values from the same fitted model as
|
|
133
|
+
_plt_fit (lm/quad/power/exp/log). R analog: .plt.fit y.new."""
|
|
134
|
+
od = np.argsort(xv, kind="stable")
|
|
135
|
+
xs, ys = xv[od], yv[od]
|
|
136
|
+
p = float(fit_power)
|
|
137
|
+
with np.errstate(invalid="ignore", divide="ignore"):
|
|
138
|
+
if fit == "quad":
|
|
139
|
+
z, p = np.sqrt(ys), 2.0
|
|
140
|
+
elif fit == "lm":
|
|
141
|
+
z = ys
|
|
142
|
+
elif fit == "power":
|
|
143
|
+
z = ys ** (1.0 / p)
|
|
144
|
+
elif fit == "exp":
|
|
145
|
+
z = np.log(ys if p == 1 else ys ** (1.0 / p))
|
|
146
|
+
else: # log
|
|
147
|
+
z = np.exp(ys if p == 1 else ys ** (1.0 / p))
|
|
148
|
+
ok = np.isfinite(z)
|
|
149
|
+
b1, b0 = np.polyfit(xs[ok], z[ok], 1)
|
|
150
|
+
xn = np.asarray(x_new, dtype=float)
|
|
151
|
+
lin = b0 + b1 * xn
|
|
152
|
+
if fit == "lm":
|
|
153
|
+
return lin
|
|
154
|
+
if fit in ("quad", "power"):
|
|
155
|
+
return lin ** p
|
|
156
|
+
if fit == "exp":
|
|
157
|
+
return np.exp(lin)
|
|
158
|
+
return np.log(lin) # log
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _fit_new_table(groups, fit, fit_power, x_new, x_name, y_name,
|
|
162
|
+
digits_d):
|
|
163
|
+
"""Printed table of fitted y at the new x values (fit_new),
|
|
164
|
+
ascending, one block per by group. R analog: out_y.new."""
|
|
165
|
+
dd = 3 if digits_d is None else digits_d
|
|
166
|
+
xn = np.sort(np.asarray(x_new, dtype=float))
|
|
167
|
+
|
|
168
|
+
def xfmt(v):
|
|
169
|
+
return str(int(v)) if v == int(v) else f"{v:.{dd}f}"
|
|
170
|
+
|
|
171
|
+
lines = []
|
|
172
|
+
for nm, xg, yg in groups:
|
|
173
|
+
xg = np.asarray(xg, dtype=float)
|
|
174
|
+
yg = np.asarray(yg, dtype=float)
|
|
175
|
+
m = np.isfinite(xg) & np.isfinite(yg)
|
|
176
|
+
if m.sum() < 2:
|
|
177
|
+
continue
|
|
178
|
+
preds = _fit_new_values(xg[m], yg[m], fit, fit_power, xn)
|
|
179
|
+
xcol = [xfmt(v) for v in xn]
|
|
180
|
+
ycol = [f"{v:.{dd}f}" for v in preds]
|
|
181
|
+
wx = max(len(x_name), *(len(s) for s in xcol))
|
|
182
|
+
yhdr = f"{y_name}_Fit"
|
|
183
|
+
wy = max(len(yhdr), *(len(s) for s in ycol))
|
|
184
|
+
lines.append("")
|
|
185
|
+
if nm is not None:
|
|
186
|
+
lines.append(f"{nm}")
|
|
187
|
+
lines.append(f" {x_name:>{wx}} {yhdr:>{wy}}")
|
|
188
|
+
for xs_, ys_ in zip(xcol, ycol):
|
|
189
|
+
lines.append(f" {xs_:>{wx}} {ys_:>{wy}}")
|
|
190
|
+
return lines
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _loess(xv, yv, span):
|
|
194
|
+
"""R-style loess at the sorted x values: local quadratic
|
|
195
|
+
regression with tri-cube weights, gaussian family, computed
|
|
196
|
+
exactly at each x (R surface="direct"). Also the SE of fit
|
|
197
|
+
from the equivalent-kernel rows, as predict(l.ln, se=TRUE).
|
|
198
|
+
R analog: .plt.fit() loess(y.lv ~ x.lv, span=span)"""
|
|
199
|
+
od = np.argsort(xv, kind="stable")
|
|
200
|
+
xs, ys = xv[od], yv[od]
|
|
201
|
+
n = len(xs)
|
|
202
|
+
q = min(n, max(int(span * n), 3)) # points in the window
|
|
203
|
+
f = np.empty(n)
|
|
204
|
+
l2 = np.empty(n) # ||l_i||^2, for se.fit
|
|
205
|
+
trL = 0.0 # trace of the smoother L
|
|
206
|
+
for i in range(n):
|
|
207
|
+
d = np.abs(xs - xs[i])
|
|
208
|
+
h = np.partition(d, q - 1)[q - 1]
|
|
209
|
+
if span > 1: # window widens past data,
|
|
210
|
+
h *= span ** 0.5 # verified against R loess
|
|
211
|
+
if h > 0:
|
|
212
|
+
w = np.clip(1 - (d / h) ** 3, 0, 1) ** 3
|
|
213
|
+
else: # window is exact x ties
|
|
214
|
+
w = (d == 0).astype(float)
|
|
215
|
+
nz = np.flatnonzero(w)
|
|
216
|
+
xc = xs[nz] - xs[i]
|
|
217
|
+
X = np.column_stack((np.ones(len(nz)), xc, xc * xc))
|
|
218
|
+
XtW = X.T * w[nz]
|
|
219
|
+
li = (np.linalg.pinv(XtW @ X) @ XtW)[0] # equiv. kernel
|
|
220
|
+
f[i] = li @ ys[nz]
|
|
221
|
+
l2[i] = li @ li
|
|
222
|
+
trL += li[np.searchsorted(nz, i)]
|
|
223
|
+
res = ys - f
|
|
224
|
+
dof = n - 2 * trL + l2.sum() # tr[(I-L)'(I-L)]
|
|
225
|
+
return xs, ys, f, np.sqrt(res @ res / dof * l2)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _se_band(xs, ys, f, level):
|
|
229
|
+
"""SE band polygon about a least-squares line: fit +/- t * SE
|
|
230
|
+
of the conditional mean. R analog: plt.main.R se bands, from
|
|
231
|
+
predict(se=TRUE) and qt(prb, n-1)."""
|
|
232
|
+
n = len(xs)
|
|
233
|
+
mse = ((ys - f) ** 2).sum() / (n - 2)
|
|
234
|
+
xbar = xs.mean()
|
|
235
|
+
sxx = ((xs - xbar) ** 2).sum()
|
|
236
|
+
se = np.sqrt(mse * (1 / n + (xs - xbar) ** 2 / sxx))
|
|
237
|
+
tq = sps.t.ppf((1 + level) / 2, n - 1)
|
|
238
|
+
return (np.concatenate([xs, xs[::-1]]),
|
|
239
|
+
np.concatenate([f + tq * se, (f - tq * se)[::-1]]))
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _ellipse_region(xv, yv, level, npoints=100):
|
|
243
|
+
"""Bivariate-normal data ellipse, the Murdoch and Chow
|
|
244
|
+
construction of the ellipse package that XY() cites.
|
|
245
|
+
R analog: ellipse::ellipse.default()"""
|
|
246
|
+
r = float(np.corrcoef(xv, yv)[0, 1])
|
|
247
|
+
d = np.arccos(np.clip(r, -1, 1))
|
|
248
|
+
t = np.sqrt(sps.chi2.ppf(level, 2))
|
|
249
|
+
theta = np.linspace(0, 2 * np.pi, npoints)
|
|
250
|
+
ex = xv.mean() + t * xv.std(ddof=1) * np.cos(theta + d / 2)
|
|
251
|
+
ey = yv.mean() + t * yv.std(ddof=1) * np.cos(theta - d / 2)
|
|
252
|
+
return ex, ey
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _xy_facet(xv, yv, by_arr, by_order, facet_arr, facet_order,
|
|
256
|
+
by_name, facet_name, fills, shape, pt_size,
|
|
257
|
+
pt_opacity, fit, fit_power, se_levels, span,
|
|
258
|
+
fit_color, fit_lwd, se_fill,
|
|
259
|
+
ellipse, ellipse_fill, ellipse_color, ellipse_lwd,
|
|
260
|
+
x_lab, y_lab, main, digits_d,
|
|
261
|
+
facet2_arr=None, facet2_order=None,
|
|
262
|
+
facet2_name=None, n_col=1):
|
|
263
|
+
"""One scatter panel per facet level on shared axes,
|
|
264
|
+
following the faceted-histogram conventions (_hs_facet):
|
|
265
|
+
first level on the bottom panel, strip labels, legend
|
|
266
|
+
entries from the first panel only. facet2: the two-facet
|
|
267
|
+
grid, rows = facet2 levels."""
|
|
268
|
+
labels, pos, sel, n_row_g, n_col = facet_panels(
|
|
269
|
+
facet_arr, facet_order, facet2_arr, facet2_order,
|
|
270
|
+
facet_name, facet2_name, n_col)
|
|
271
|
+
n_f = len(labels)
|
|
272
|
+
groups = by_order if by_order is not None else [None]
|
|
273
|
+
has_groups = by_order is not None
|
|
274
|
+
px = float(pt_size) * (6.5 if has_groups else 7.25)
|
|
275
|
+
if not np.isfinite(px) or px < 0:
|
|
276
|
+
px = 5
|
|
277
|
+
|
|
278
|
+
# per-panel artifacts, plus every plotted value for the
|
|
279
|
+
# shared axis scaling
|
|
280
|
+
panels = []
|
|
281
|
+
xs_all, ys_all = [xv], [yv]
|
|
282
|
+
for in_lvl in sel:
|
|
283
|
+
pts, fit_lines, se_polys, ellipses = [], [], [], []
|
|
284
|
+
for g, gname in enumerate(groups):
|
|
285
|
+
m = in_lvl if gname is None \
|
|
286
|
+
else in_lvl & (by_arr == gname)
|
|
287
|
+
xg, yg = xv[m], yv[m]
|
|
288
|
+
pts.append((gname, xg, yg))
|
|
289
|
+
if fit != "off" and len(xg) >= 2:
|
|
290
|
+
if fit == "loess":
|
|
291
|
+
xs, ys_s, f, se_f = _loess(xg, yg, span)
|
|
292
|
+
fit_lines.append({"name": gname, "x": xs,
|
|
293
|
+
"y": f, "g": g})
|
|
294
|
+
for lv in se_levels:
|
|
295
|
+
tq = sps.t.ppf((1 + lv) / 2, len(xs) - 1)
|
|
296
|
+
se_polys.append((
|
|
297
|
+
np.concatenate([xs, xs[::-1]]),
|
|
298
|
+
np.concatenate(
|
|
299
|
+
[f + tq * se_f,
|
|
300
|
+
(f - tq * se_f)[::-1]])))
|
|
301
|
+
else:
|
|
302
|
+
xs, ys_s, f = _plt_fit(xg, yg, fit, fit_power)
|
|
303
|
+
okf = np.isfinite(f)
|
|
304
|
+
fit_lines.append({"name": gname, "x": xs[okf],
|
|
305
|
+
"y": f[okf], "g": g})
|
|
306
|
+
if fit == "lm":
|
|
307
|
+
for lv in se_levels:
|
|
308
|
+
se_polys.append(
|
|
309
|
+
_se_band(xs, ys_s, f, lv))
|
|
310
|
+
if ellipse and len(xg) >= 3:
|
|
311
|
+
ellipses.append((g, *_ellipse_region(xg, yg,
|
|
312
|
+
ellipse)))
|
|
313
|
+
panels.append((pts, fit_lines, se_polys, ellipses))
|
|
314
|
+
for fl in fit_lines:
|
|
315
|
+
xs_all.append(fl["x"]); ys_all.append(fl["y"])
|
|
316
|
+
for bx, bnd in se_polys:
|
|
317
|
+
ys_all.append(bnd)
|
|
318
|
+
for _, ex, ey in ellipses:
|
|
319
|
+
xs_all.append(ex); ys_all.append(ey)
|
|
320
|
+
|
|
321
|
+
xs_all = np.concatenate(xs_all)
|
|
322
|
+
ys_all = np.concatenate(ys_all)
|
|
323
|
+
axT1 = pretty(float(np.nanmin(xs_all)), float(np.nanmax(xs_all)))
|
|
324
|
+
axT2 = pretty(float(np.nanmin(ys_all)), float(np.nanmax(ys_all)))
|
|
325
|
+
ax = {"axT1": axT1,
|
|
326
|
+
"axL1": axis_format(axT1, digits_d),
|
|
327
|
+
"axT2": axT2,
|
|
328
|
+
"axL2": axis_format(axT2, digits_d)}
|
|
329
|
+
|
|
330
|
+
style_opts = plotly_style()
|
|
331
|
+
fig = facet_fig(n_row_g, n_col)
|
|
332
|
+
for i in range(n_f):
|
|
333
|
+
pts, fit_lines, se_polys, ellipses = panels[i]
|
|
334
|
+
row, col = pos[i]
|
|
335
|
+
for g, (gname, xg, yg) in enumerate(pts):
|
|
336
|
+
fig.add_trace(go.Scatter(
|
|
337
|
+
x=xg, y=yg, mode="markers",
|
|
338
|
+
name=None if gname is None else str(gname),
|
|
339
|
+
legendgroup=(None if gname is None
|
|
340
|
+
else str(gname)),
|
|
341
|
+
marker=dict(
|
|
342
|
+
symbol=sym_at(shape, g), size=px,
|
|
343
|
+
sizemode="diameter",
|
|
344
|
+
color=make_trans(fills[g], pt_opacity),
|
|
345
|
+
opacity=1,
|
|
346
|
+
line=dict(color=to_hex(fills[g]), width=1)),
|
|
347
|
+
hoverinfo="x+y" + ("+name" if has_groups else ""),
|
|
348
|
+
showlegend=has_groups and i == 0,
|
|
349
|
+
), row=row, col=col)
|
|
350
|
+
for g, ex, ey in ellipses:
|
|
351
|
+
edge = (to_hex(fills[g]) if has_groups
|
|
352
|
+
else to_hex(ellipse_color))
|
|
353
|
+
fig.add_trace(go.Scatter(
|
|
354
|
+
x=ex, y=ey, mode="lines",
|
|
355
|
+
line=dict(color=edge, width=ellipse_lwd),
|
|
356
|
+
fill="toself",
|
|
357
|
+
fillcolor=as_plotly_color(ellipse_fill),
|
|
358
|
+
hoverinfo="skip", showlegend=False,
|
|
359
|
+
), row=row, col=col)
|
|
360
|
+
for bx, bnd in se_polys:
|
|
361
|
+
fig.add_trace(go.Scatter(
|
|
362
|
+
x=bx, y=bnd, mode="none", fill="toself",
|
|
363
|
+
fillcolor=as_plotly_color(se_fill),
|
|
364
|
+
hoverinfo="skip", showlegend=False,
|
|
365
|
+
), row=row, col=col)
|
|
366
|
+
for fl in fit_lines:
|
|
367
|
+
if len(fl["x"]) < 2:
|
|
368
|
+
continue
|
|
369
|
+
single = fl["name"] is None
|
|
370
|
+
fig.add_trace(go.Scatter(
|
|
371
|
+
x=fl["x"], y=fl["y"], mode="lines",
|
|
372
|
+
name="Fit" if single else f"Fit: {fl['name']}",
|
|
373
|
+
legendgroup=("fit" if single
|
|
374
|
+
else str(fl["name"])),
|
|
375
|
+
line=dict(color=(to_hex(fit_color) if single
|
|
376
|
+
else to_hex(fills[fl["g"]])),
|
|
377
|
+
width=fit_lwd),
|
|
378
|
+
hoverinfo="skip",
|
|
379
|
+
showlegend=i == 0,
|
|
380
|
+
), row=row, col=col)
|
|
381
|
+
|
|
382
|
+
finish_facet(fig, labels, ax, x_lab, y_lab,
|
|
383
|
+
gridT1=axT1, style_opts=style_opts,
|
|
384
|
+
n_col=n_col, pos=pos)
|
|
385
|
+
# scatter panels: y spans the data, not [0, max] as counts do
|
|
386
|
+
pad = 0.04 * (axT2[-1] - axT2[0])
|
|
387
|
+
fig.update_yaxes(range=[axT2[0] - pad, axT2[-1] + pad])
|
|
388
|
+
if has_groups:
|
|
389
|
+
fig.update_layout(legend=legend_style(by_name,
|
|
390
|
+
style_opts))
|
|
391
|
+
if main:
|
|
392
|
+
title_size = round(16 * get_option("main_size", 1))
|
|
393
|
+
fig.update_layout(
|
|
394
|
+
title=dict(text=main, x=0.5, xanchor="center",
|
|
395
|
+
font=dict(size=title_size)))
|
|
396
|
+
return fig
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _series_pts(V, shared, m, g, on):
|
|
400
|
+
"""Panel points of series g under mask m, oriented per on=:
|
|
401
|
+
"x" plots the series values on the x axis against the shared
|
|
402
|
+
variable, "y" the reverse."""
|
|
403
|
+
vg = V[m, g]
|
|
404
|
+
sg = shared[m]
|
|
405
|
+
return (vg, sg) if on == "x" else (sg, vg)
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _series_overlay(V, names, shared, on, fills, shape, pt_size,
|
|
409
|
+
pt_opacity, fit, fit_power, se_levels, span,
|
|
410
|
+
fit_lwd, se_fill,
|
|
411
|
+
ellipse, ellipse_fill, ellipse_lwd,
|
|
412
|
+
x_lab, y_lab, main, digits_d):
|
|
413
|
+
"""The multi-series overlay on one panel: the columns of V
|
|
414
|
+
against the single shared variable, one color per series,
|
|
415
|
+
optional per-series fit (in the series color), SE bands, and
|
|
416
|
+
data ellipses. R analog: the .plt.main overlay of a vector
|
|
417
|
+
of x (or y) variables."""
|
|
418
|
+
k = V.shape[1]
|
|
419
|
+
px = float(pt_size) * 6.5
|
|
420
|
+
if not np.isfinite(px) or px < 0:
|
|
421
|
+
px = 5
|
|
422
|
+
all_m = np.ones(len(shared), dtype=bool)
|
|
423
|
+
|
|
424
|
+
xs_all, ys_all = [], []
|
|
425
|
+
art = []
|
|
426
|
+
for g in range(k):
|
|
427
|
+
xg, yg = _series_pts(V, shared, all_m, g, on)
|
|
428
|
+
fit_lines, se_polys, ellipses = [], [], []
|
|
429
|
+
if fit != "off" and len(xg) >= 2:
|
|
430
|
+
if fit == "loess":
|
|
431
|
+
xs, ys_s, f, se_f = _loess(xg, yg, span)
|
|
432
|
+
fit_lines.append((xs, f))
|
|
433
|
+
for lv in se_levels:
|
|
434
|
+
tq = sps.t.ppf((1 + lv) / 2, len(xs) - 1)
|
|
435
|
+
se_polys.append((
|
|
436
|
+
np.concatenate([xs, xs[::-1]]),
|
|
437
|
+
np.concatenate([f + tq * se_f,
|
|
438
|
+
(f - tq * se_f)[::-1]])))
|
|
439
|
+
else:
|
|
440
|
+
xs, ys_s, f = _plt_fit(xg, yg, fit, fit_power)
|
|
441
|
+
okf = np.isfinite(f)
|
|
442
|
+
fit_lines.append((xs[okf], f[okf]))
|
|
443
|
+
if fit == "lm":
|
|
444
|
+
for lv in se_levels:
|
|
445
|
+
se_polys.append(_se_band(xs, ys_s, f, lv))
|
|
446
|
+
if ellipse and len(xg) >= 3:
|
|
447
|
+
ellipses.append(_ellipse_region(xg, yg, ellipse))
|
|
448
|
+
art.append((xg, yg, fit_lines, se_polys, ellipses))
|
|
449
|
+
xs_all += [xg] + [fl[0] for fl in fit_lines] \
|
|
450
|
+
+ [ex for ex, _ in ellipses]
|
|
451
|
+
ys_all += [yg] + [fl[1] for fl in fit_lines] \
|
|
452
|
+
+ [bnd for _, bnd in se_polys] \
|
|
453
|
+
+ [ey for _, ey in ellipses]
|
|
454
|
+
|
|
455
|
+
xs_all = np.concatenate(xs_all)
|
|
456
|
+
ys_all = np.concatenate(ys_all)
|
|
457
|
+
axT1 = pretty(float(np.nanmin(xs_all)),
|
|
458
|
+
float(np.nanmax(xs_all)))
|
|
459
|
+
axT2 = pretty(float(np.nanmin(ys_all)),
|
|
460
|
+
float(np.nanmax(ys_all)))
|
|
461
|
+
|
|
462
|
+
style_opts = plotly_style()
|
|
463
|
+
fig = go.Figure()
|
|
464
|
+
for g, nm in enumerate(names):
|
|
465
|
+
xg, yg, fit_lines, se_polys, ellipses = art[g]
|
|
466
|
+
for ex, ey in ellipses:
|
|
467
|
+
fig.add_trace(go.Scatter(
|
|
468
|
+
x=ex, y=ey, mode="lines",
|
|
469
|
+
line=dict(color=to_hex(fills[g]),
|
|
470
|
+
width=ellipse_lwd),
|
|
471
|
+
fill="toself",
|
|
472
|
+
fillcolor=as_plotly_color(ellipse_fill),
|
|
473
|
+
hoverinfo="skip", showlegend=False))
|
|
474
|
+
for bx, bnd in se_polys:
|
|
475
|
+
fig.add_trace(go.Scatter(
|
|
476
|
+
x=bx, y=bnd, mode="none", fill="toself",
|
|
477
|
+
fillcolor=as_plotly_color(se_fill),
|
|
478
|
+
hoverinfo="skip", showlegend=False))
|
|
479
|
+
fig.add_trace(go.Scatter(
|
|
480
|
+
x=xg, y=yg, mode="markers", name=str(nm),
|
|
481
|
+
legendgroup=str(nm),
|
|
482
|
+
marker=dict(symbol=sym_at(shape, g), size=px,
|
|
483
|
+
sizemode="diameter",
|
|
484
|
+
color=make_trans(fills[g], pt_opacity),
|
|
485
|
+
opacity=1,
|
|
486
|
+
line=dict(color=to_hex(fills[g]),
|
|
487
|
+
width=1)),
|
|
488
|
+
hoverinfo="x+y+name", showlegend=True))
|
|
489
|
+
for fx_l, fy_l in fit_lines:
|
|
490
|
+
if len(fx_l) < 2:
|
|
491
|
+
continue
|
|
492
|
+
fig.add_trace(go.Scatter(
|
|
493
|
+
x=fx_l, y=fy_l, mode="lines",
|
|
494
|
+
legendgroup=str(nm),
|
|
495
|
+
line=dict(color=to_hex(fills[g]),
|
|
496
|
+
width=fit_lwd),
|
|
497
|
+
hoverinfo="skip", showlegend=False))
|
|
498
|
+
|
|
499
|
+
ax_x = axis_num(x_lab, axT1, axis_format(axT1, digits_d))
|
|
500
|
+
ax_y = axis_num(y_lab, axT2, axis_format(axT2, digits_d))
|
|
501
|
+
pad = 0.04 * (axT2[-1] - axT2[0])
|
|
502
|
+
ax_y["range"] = [axT2[0] - pad, axT2[-1] + pad]
|
|
503
|
+
ax_y.update(showgrid=True,
|
|
504
|
+
gridcolor=to_hex(style_opts["grid_col"]),
|
|
505
|
+
gridwidth=1, griddash="dot")
|
|
506
|
+
fig.update_layout(
|
|
507
|
+
xaxis=ax_x, yaxis=ax_y,
|
|
508
|
+
shapes=x_grid(axT1) + plot_border(),
|
|
509
|
+
template=None,
|
|
510
|
+
plot_bgcolor=to_hex(style_opts["panel_fill"]),
|
|
511
|
+
paper_bgcolor=to_hex(style_opts["window_fill"]),
|
|
512
|
+
legend=legend_style("", style_opts),
|
|
513
|
+
**square_layout(main=bool(main)))
|
|
514
|
+
if main:
|
|
515
|
+
title_size = round(16 * get_option("main_size", 1))
|
|
516
|
+
fig.update_layout(
|
|
517
|
+
title=dict(text=main, x=0.5, xanchor="center",
|
|
518
|
+
font=dict(size=title_size)))
|
|
519
|
+
return fig
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def _facet_series(V, names, shared, on, facet_arr, facet_order,
|
|
523
|
+
facet_name, facet2_arr, facet2_order,
|
|
524
|
+
facet2_name, n_col, fills, shape, pt_size,
|
|
525
|
+
pt_opacity, fit, fit_power, span, fit_lwd,
|
|
526
|
+
x_lab, y_lab, main, digits_d):
|
|
527
|
+
"""The multi-series overlay repeated across the facet
|
|
528
|
+
panels: each panel the full overlay for one facet level (or
|
|
529
|
+
cell), per-series fit in the series color, all panels on a
|
|
530
|
+
common scale, series legend from the first panel. Default a
|
|
531
|
+
single column of panels, as the generic facet path.
|
|
532
|
+
R analog: .plt.facet.series()"""
|
|
533
|
+
labels, pos, sel, n_row_g, n_col = facet_panels(
|
|
534
|
+
facet_arr, facet_order, facet2_arr, facet2_order,
|
|
535
|
+
facet_name, facet2_name, n_col)
|
|
536
|
+
n_f = len(labels)
|
|
537
|
+
k = V.shape[1]
|
|
538
|
+
px = float(pt_size) * 6.5
|
|
539
|
+
if not np.isfinite(px) or px < 0:
|
|
540
|
+
px = 5
|
|
541
|
+
|
|
542
|
+
panels = []
|
|
543
|
+
xs_all, ys_all = [], []
|
|
544
|
+
for i in range(n_f):
|
|
545
|
+
pts, fit_lines = [], []
|
|
546
|
+
for g in range(k):
|
|
547
|
+
xg, yg = _series_pts(V, shared, sel[i], g, on)
|
|
548
|
+
pts.append((xg, yg))
|
|
549
|
+
xs_all.append(xg)
|
|
550
|
+
ys_all.append(yg)
|
|
551
|
+
ok = np.isfinite(xg) & np.isfinite(yg)
|
|
552
|
+
if fit != "off" and ok.sum() > 2:
|
|
553
|
+
if fit == "loess":
|
|
554
|
+
xs, _, f, _ = _loess(xg[ok], yg[ok], span)
|
|
555
|
+
else:
|
|
556
|
+
xs, _, f = _plt_fit(xg[ok], yg[ok], fit,
|
|
557
|
+
fit_power)
|
|
558
|
+
okf = np.isfinite(f)
|
|
559
|
+
fit_lines.append((g, xs[okf], f[okf]))
|
|
560
|
+
xs_all.append(xs[okf])
|
|
561
|
+
ys_all.append(f[okf])
|
|
562
|
+
panels.append((pts, fit_lines))
|
|
563
|
+
|
|
564
|
+
xs_all = np.concatenate(xs_all)
|
|
565
|
+
ys_all = np.concatenate(ys_all)
|
|
566
|
+
axT1 = pretty(float(np.nanmin(xs_all)),
|
|
567
|
+
float(np.nanmax(xs_all)))
|
|
568
|
+
axT2 = pretty(float(np.nanmin(ys_all)),
|
|
569
|
+
float(np.nanmax(ys_all)))
|
|
570
|
+
ax = {"axT1": axT1,
|
|
571
|
+
"axL1": axis_format(axT1, digits_d),
|
|
572
|
+
"axT2": axT2,
|
|
573
|
+
"axL2": axis_format(axT2, digits_d)}
|
|
574
|
+
|
|
575
|
+
style_opts = plotly_style()
|
|
576
|
+
fig = facet_fig(n_row_g, n_col)
|
|
577
|
+
for i in range(n_f):
|
|
578
|
+
pts, fit_lines = panels[i]
|
|
579
|
+
row, col = pos[i]
|
|
580
|
+
for g, nm in enumerate(names):
|
|
581
|
+
xg, yg = pts[g]
|
|
582
|
+
fig.add_trace(go.Scatter(
|
|
583
|
+
x=xg, y=yg, mode="markers", name=str(nm),
|
|
584
|
+
legendgroup=str(nm),
|
|
585
|
+
marker=dict(symbol=shape, size=px,
|
|
586
|
+
sizemode="diameter",
|
|
587
|
+
color=make_trans(fills[g],
|
|
588
|
+
pt_opacity),
|
|
589
|
+
opacity=1,
|
|
590
|
+
line=dict(color=to_hex(fills[g]),
|
|
591
|
+
width=1)),
|
|
592
|
+
hoverinfo="x+y+name",
|
|
593
|
+
showlegend=i == 0,
|
|
594
|
+
), row=row, col=col)
|
|
595
|
+
for g, fx_l, fy_l in fit_lines:
|
|
596
|
+
if len(fx_l) < 2:
|
|
597
|
+
continue
|
|
598
|
+
fig.add_trace(go.Scatter(
|
|
599
|
+
x=fx_l, y=fy_l, mode="lines",
|
|
600
|
+
legendgroup=str(names[g]),
|
|
601
|
+
line=dict(color=to_hex(fills[g]),
|
|
602
|
+
width=fit_lwd),
|
|
603
|
+
hoverinfo="skip", showlegend=False,
|
|
604
|
+
), row=row, col=col)
|
|
605
|
+
|
|
606
|
+
finish_facet(fig, labels, ax, x_lab, y_lab,
|
|
607
|
+
gridT1=axT1, style_opts=style_opts,
|
|
608
|
+
n_col=n_col, pos=pos)
|
|
609
|
+
pad = 0.04 * (axT2[-1] - axT2[0])
|
|
610
|
+
fig.update_yaxes(range=[axT2[0] - pad, axT2[-1] + pad])
|
|
611
|
+
fig.update_layout(legend=legend_style("", style_opts))
|
|
612
|
+
if main:
|
|
613
|
+
title_size = round(16 * get_option("main_size", 1))
|
|
614
|
+
fig.update_layout(
|
|
615
|
+
title=dict(text=main, x=0.5, xanchor="center",
|
|
616
|
+
font=dict(size=title_size)))
|
|
617
|
+
return fig
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def _xy_series(x, y, data, x_multi, by, facet, form, n_row,
|
|
621
|
+
n_col, fill, transparency, shape, pt_size,
|
|
622
|
+
fit, fit_power, fit_se, fit_lwd, span,
|
|
623
|
+
ellipse, ellipse_fill, ellipse_lwd,
|
|
624
|
+
MD_cut, out_cut,
|
|
625
|
+
xlab, ylab, main, digits_d, quiet):
|
|
626
|
+
"""Resolve and dispatch a vector of x (or y) variables: the
|
|
627
|
+
single-panel multi-series overlay, or with facet= the
|
|
628
|
+
facet-series panels. R analog: the nn.col > 1 paths of
|
|
629
|
+
XY.R."""
|
|
630
|
+
names = list(x) if x_multi else list(y)
|
|
631
|
+
shared_name = y if x_multi else x
|
|
632
|
+
if not isinstance(shared_name, str):
|
|
633
|
+
raise TypeError(
|
|
634
|
+
"with a vector of variables, the other axis is a "
|
|
635
|
+
"single column name")
|
|
636
|
+
if form != "scatter":
|
|
637
|
+
raise ValueError(
|
|
638
|
+
'a vector of variables overlays series: '
|
|
639
|
+
'form="scatter"')
|
|
640
|
+
if by is not None:
|
|
641
|
+
raise ValueError(
|
|
642
|
+
"a multi-series overlay already colors by series. "
|
|
643
|
+
"To display a second grouping variable, use facet=")
|
|
644
|
+
if MD_cut > 0 or out_cut > 0:
|
|
645
|
+
raise ValueError(
|
|
646
|
+
"outlier flagging (MD_cut/out_cut) applies to a "
|
|
647
|
+
"single scatterplot")
|
|
648
|
+
|
|
649
|
+
arg = "x" if x_multi else "y"
|
|
650
|
+
cols = [get_column(data, nm, arg) for nm in names]
|
|
651
|
+
shared_ser = get_column(data, shared_name,
|
|
652
|
+
"y" if x_multi else "x")
|
|
653
|
+
for nm, s in zip(names + [shared_name],
|
|
654
|
+
cols + [shared_ser]):
|
|
655
|
+
if pd.api.types.is_datetime64_any_dtype(s):
|
|
656
|
+
raise NotImplementedError(
|
|
657
|
+
"multiple series on a date axis: use tidy "
|
|
658
|
+
"(long) data with by=")
|
|
659
|
+
if not pd.api.types.is_numeric_dtype(s):
|
|
660
|
+
raise TypeError(
|
|
661
|
+
f"the multi-series overlay is numeric, but "
|
|
662
|
+
f"'{nm}' is {s.dtype}")
|
|
663
|
+
|
|
664
|
+
f1_ser, f1_name, f2_ser, f2_name = resolve_facet(
|
|
665
|
+
data, facet, "XY")
|
|
666
|
+
used = cols + [shared_ser] \
|
|
667
|
+
+ [s for s in (f1_ser, f2_ser) if s is not None]
|
|
668
|
+
keep = ~pd.concat(used, axis=1).isna().any(axis=1)
|
|
669
|
+
V = np.column_stack([c[keep].to_numpy(dtype=float)
|
|
670
|
+
for c in cols])
|
|
671
|
+
shared_v = shared_ser[keep].to_numpy(dtype=float)
|
|
672
|
+
on = "x" if x_multi else "y"
|
|
673
|
+
ser_lab = ", ".join(names)
|
|
674
|
+
x_lab = ((ser_lab if x_multi else shared_name)
|
|
675
|
+
if xlab is None else xlab)
|
|
676
|
+
y_lab = ((shared_name if x_multi else ser_lab)
|
|
677
|
+
if ylab is None else ylab)
|
|
678
|
+
|
|
679
|
+
k = len(names)
|
|
680
|
+
if isinstance(fill, (list, tuple)):
|
|
681
|
+
fills = [fill[i % len(fill)] for i in range(k)]
|
|
682
|
+
elif fill is not None:
|
|
683
|
+
fills = [fill] * k
|
|
684
|
+
else:
|
|
685
|
+
fills = [BASE_COLORS[i % len(BASE_COLORS)]
|
|
686
|
+
for i in range(k)]
|
|
687
|
+
if transparency is None:
|
|
688
|
+
transparency = get_option("trans_pt_fill", 0.10)
|
|
689
|
+
if ellipse is True:
|
|
690
|
+
ellipse = 0.95
|
|
691
|
+
if fit == "ls":
|
|
692
|
+
fit = "lm"
|
|
693
|
+
# SE bands off by default with several series, as by= groups
|
|
694
|
+
se_lv = [lv for lv in (fit_se if isinstance(
|
|
695
|
+
fit_se, (list, tuple)) else [fit_se]) if lv]
|
|
696
|
+
dd = 2 if digits_d is None else digits_d
|
|
697
|
+
fit_lwd_use = (get_option("fit_lwd", 2)
|
|
698
|
+
if fit_lwd is None else fit_lwd)
|
|
699
|
+
|
|
700
|
+
if not resolve_quiet(quiet): # per-series relationship
|
|
701
|
+
all_m = np.ones(len(shared_v), dtype=bool)
|
|
702
|
+
groups = [(nm, *_series_pts(V, shared_v, all_m, g, on))
|
|
703
|
+
for g, nm in enumerate(names)]
|
|
704
|
+
print("\n".join(xy_stats(groups, x_lab, y_lab,
|
|
705
|
+
digits_d=dd)))
|
|
706
|
+
|
|
707
|
+
if f1_ser is None:
|
|
708
|
+
return _series_overlay(
|
|
709
|
+
V, names, shared_v, on, fills, shape, pt_size,
|
|
710
|
+
1 - transparency, fit, fit_power, se_lv, span,
|
|
711
|
+
fit_lwd_use, get_option("se_fill", "#1A1A1A19"),
|
|
712
|
+
ellipse,
|
|
713
|
+
(get_option("ellipse_fill", "#92806F28")
|
|
714
|
+
if ellipse_fill is None else ellipse_fill),
|
|
715
|
+
(get_option("ellipse_lwd", 1)
|
|
716
|
+
if ellipse_lwd is None else ellipse_lwd),
|
|
717
|
+
x_lab, y_lab, main, dd)
|
|
718
|
+
|
|
719
|
+
f1a = f1_ser[keep].to_numpy()
|
|
720
|
+
f1o = category_order(f1_ser[keep])
|
|
721
|
+
f2a = f2_ser[keep].to_numpy() if f2_ser is not None else None
|
|
722
|
+
f2o = (category_order(f2_ser[keep])
|
|
723
|
+
if f2_ser is not None else None)
|
|
724
|
+
n_col_use = (int(n_col) if n_col is not None
|
|
725
|
+
else (math.ceil(len(f1o) / int(n_row))
|
|
726
|
+
if n_row is not None else 1))
|
|
727
|
+
return _facet_series(
|
|
728
|
+
V, names, shared_v, on, f1a, f1o, f1_name,
|
|
729
|
+
f2a, f2o, f2_name, n_col_use, fills, shape, pt_size,
|
|
730
|
+
1 - transparency, fit, fit_power, span, fit_lwd_use,
|
|
731
|
+
x_lab, y_lab, main, dd)
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
def _ts_facet(xv, yv, facet_arr, facet_order, fill0, border0,
|
|
735
|
+
pt_size, pt_opacity, x_lab, y_lab, main, digits_d,
|
|
736
|
+
area_fill=None, area_split=0,
|
|
737
|
+
facet_name=None, facet2_arr=None,
|
|
738
|
+
facet2_order=None, facet2_name=None, n_col=1):
|
|
739
|
+
"""One time-series panel per facet level on shared axes,
|
|
740
|
+
following the faceted-scatter conventions (_xy_facet):
|
|
741
|
+
first level on the bottom panel, strip labels. The x axis
|
|
742
|
+
uses plotly's native date ticks, as the single-panel time
|
|
743
|
+
series does. R analog: the lattice cont_cont path for a
|
|
744
|
+
date x. facet2: the two-facet grid, rows = facet2 levels."""
|
|
745
|
+
labels, pos, sel, n_row_g, n_col = facet_panels(
|
|
746
|
+
facet_arr, facet_order, facet2_arr, facet2_order,
|
|
747
|
+
facet_name, facet2_name, n_col)
|
|
748
|
+
n_f = len(labels)
|
|
749
|
+
px = float(pt_size) * 6.5 # panel-scaled points
|
|
750
|
+
if not np.isfinite(px) or px < 0:
|
|
751
|
+
px = 5
|
|
752
|
+
mode_pts = "lines+markers" if px > 0 else "lines"
|
|
753
|
+
|
|
754
|
+
axT2 = pretty(float(np.nanmin(yv)), float(np.nanmax(yv)))
|
|
755
|
+
fmt2 = get_tick_fmt(axT2, digits_d)
|
|
756
|
+
ax = {"axT1": None, "axL1": None,
|
|
757
|
+
"axT2": axT2,
|
|
758
|
+
"axL2": axis_format(axT2, digits_d)}
|
|
759
|
+
|
|
760
|
+
ypart = f"%{{y:{fmt2}}}" if fmt2 else "%{y}"
|
|
761
|
+
hover = (f"Date: %{{x|%Y-%m-%d}}<br>{y_lab.strip()}: "
|
|
762
|
+
f"{ypart}<extra></extra>")
|
|
763
|
+
|
|
764
|
+
style_opts = plotly_style()
|
|
765
|
+
fig = facet_fig(n_row_g, n_col)
|
|
766
|
+
if area_fill is not None: # fill toward area_split,
|
|
767
|
+
rng_pad = 0.04 * (axT2[-1] - axT2[0]) # panel-clipped
|
|
768
|
+
base = min(max(float(area_split), axT2[0] - rng_pad),
|
|
769
|
+
axT2[-1] + rng_pad)
|
|
770
|
+
for i in range(n_f):
|
|
771
|
+
m = sel[i]
|
|
772
|
+
row, col = pos[i]
|
|
773
|
+
if area_fill is not None and m.any():
|
|
774
|
+
fig.add_trace(go.Scatter(
|
|
775
|
+
x=np.concatenate([xv[m], xv[m][-1:],
|
|
776
|
+
xv[m][:1]]),
|
|
777
|
+
y=np.concatenate([yv[m], [base, base]]),
|
|
778
|
+
mode="none", fill="toself",
|
|
779
|
+
fillcolor=area_fill, hoverinfo="skip",
|
|
780
|
+
showlegend=False,
|
|
781
|
+
), row=row, col=col)
|
|
782
|
+
fig.add_trace(go.Scatter(
|
|
783
|
+
x=xv[m], y=yv[m], mode=mode_pts,
|
|
784
|
+
marker=(dict(symbol="circle", size=px,
|
|
785
|
+
sizemode="diameter",
|
|
786
|
+
color=make_trans(fill0, pt_opacity),
|
|
787
|
+
opacity=1,
|
|
788
|
+
line=dict(color=to_hex(border0),
|
|
789
|
+
width=1))
|
|
790
|
+
if "markers" in mode_pts else None),
|
|
791
|
+
line=dict(color=to_hex(border0), width=1.5),
|
|
792
|
+
hovertemplate=hover, showlegend=False,
|
|
793
|
+
), row=row, col=col) # first level bottom
|
|
794
|
+
|
|
795
|
+
finish_facet(fig, labels, ax, x_lab, y_lab,
|
|
796
|
+
gridT1=None, style_opts=style_opts,
|
|
797
|
+
n_col=n_col, pos=pos)
|
|
798
|
+
# ts panels: y spans the data; native date grid on x
|
|
799
|
+
pad = 0.04 * (axT2[-1] - axT2[0])
|
|
800
|
+
fig.update_yaxes(range=[axT2[0] - pad, axT2[-1] + pad])
|
|
801
|
+
fig.update_xaxes(showgrid=True,
|
|
802
|
+
gridcolor=to_hex(style_opts["grid_col"]),
|
|
803
|
+
gridwidth=1)
|
|
804
|
+
if main:
|
|
805
|
+
title_size = round(16 * get_option("main_size", 1))
|
|
806
|
+
fig.update_layout(
|
|
807
|
+
title=dict(text=main, x=0.5, xanchor="center",
|
|
808
|
+
font=dict(size=title_size)))
|
|
809
|
+
return fig
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
def _area_color(col, opacity):
|
|
813
|
+
"""Area fill color: "on" is the violin_fill option; an
|
|
814
|
+
explicit #RRGGBBAA keeps its own alpha; otherwise the point
|
|
815
|
+
transparency applies, as R's .maketrans of area_fill."""
|
|
816
|
+
if col == "on":
|
|
817
|
+
col = get_option("violin_fill", "#7485975A")
|
|
818
|
+
if (isinstance(col, str) and col.startswith("#")
|
|
819
|
+
and len(col) == 9):
|
|
820
|
+
return as_plotly_color(col)
|
|
821
|
+
return make_trans(col, opacity)
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
def _apply_rotate(fig, rotate_x, rotate_y):
|
|
825
|
+
"""Tick-label rotation, all panels. R: rotate_x/rotate_y."""
|
|
826
|
+
if rotate_x:
|
|
827
|
+
fig.update_xaxes(tickangle=-float(rotate_x))
|
|
828
|
+
if rotate_y:
|
|
829
|
+
fig.update_yaxes(tickangle=-float(rotate_y))
|
|
830
|
+
return fig
|
|
831
|
+
|
|
832
|
+
|
|
833
|
+
def _add_means(fig, xv, yv):
|
|
834
|
+
"""The add="means" crosshair set by enhance: a line at the
|
|
835
|
+
mean of each variable, spanning the panel.
|
|
836
|
+
R analog: plt.main.R annotations (~1403-1424)"""
|
|
837
|
+
line = dict(color="#1A1A1A", width=1) # add_color gray10
|
|
838
|
+
fig.add_shape(type="line", xref="x", yref="paper",
|
|
839
|
+
x0=float(xv.mean()), x1=float(xv.mean()),
|
|
840
|
+
y0=0, y1=1, line=line)
|
|
841
|
+
fig.add_shape(type="line", xref="paper", yref="y",
|
|
842
|
+
x0=0, x1=1, y0=float(yv.mean()),
|
|
843
|
+
y1=float(yv.mean()), line=line)
|
|
844
|
+
|
|
845
|
+
|
|
846
|
+
def _run_analysis(yv, digits_d, show_detail):
|
|
847
|
+
"""Runs test for a run chart: count the consecutive runs of y on
|
|
848
|
+
each side of its median, optionally listing each run's members.
|
|
849
|
+
Returns (median, text lines). R analog: the Run Analysis of
|
|
850
|
+
.plt.txt."""
|
|
851
|
+
import numpy as np
|
|
852
|
+
y = np.asarray(yv, dtype=float)
|
|
853
|
+
n = len(y)
|
|
854
|
+
m = float(np.median(y))
|
|
855
|
+
dd = 2 if digits_d is None else digits_d
|
|
856
|
+
|
|
857
|
+
def sgn(v):
|
|
858
|
+
v = float(v)
|
|
859
|
+
return (v > 0) - (v < 0)
|
|
860
|
+
|
|
861
|
+
run, members = [1], [[1]]
|
|
862
|
+
for i in range(1, n):
|
|
863
|
+
if y[i] != m and sgn(y[i] - m) != sgn(y[i - 1] - m):
|
|
864
|
+
run.append(0)
|
|
865
|
+
members.append([])
|
|
866
|
+
run[-1] += 1
|
|
867
|
+
members[-1].append(i + 1)
|
|
868
|
+
n_runs = len(run)
|
|
869
|
+
eq = [j + 1 for j in range(n) if y[j] == m]
|
|
870
|
+
|
|
871
|
+
lines = ["", "-" * 12, "Run Analysis", "-" * 12]
|
|
872
|
+
if show_detail:
|
|
873
|
+
lines.append("")
|
|
874
|
+
for k in range(n_runs):
|
|
875
|
+
if run[k] > 1:
|
|
876
|
+
idxs = " ".join(f"{j:>3}" for j in members[k])
|
|
877
|
+
lines.append(
|
|
878
|
+
f"size={run[k]:>3} Run {k + 1:>3} : {idxs}")
|
|
879
|
+
lines.append(f"\nTotal number of runs: {n_runs}")
|
|
880
|
+
lines.append("Total number of values that do not equal the "
|
|
881
|
+
f"median: {n - len(eq)}")
|
|
882
|
+
if eq:
|
|
883
|
+
if show_detail:
|
|
884
|
+
lines.append("\nValues ignored that equal the median")
|
|
885
|
+
lines += [f" #{j} {y[j - 1]:.{dd}f}" for j in eq]
|
|
886
|
+
lines.append(f"Total number of values ignored: {len(eq)}")
|
|
887
|
+
return m, lines
|
|
888
|
+
|
|
889
|
+
|
|
890
|
+
def _outlier_traces(fig, xv, yv, out_idx, labels, fill0,
|
|
891
|
+
pt_opacity, pt_size, out_shape, out_size,
|
|
892
|
+
ID_color, ID_size):
|
|
893
|
+
"""Overdraw the flagged outliers and label each with its ID.
|
|
894
|
+
R analog: plt.plotly.R outlier points/labels (~140-185):
|
|
895
|
+
open symbol, same fill and size, ID annotation below."""
|
|
896
|
+
px = float(pt_size) * float(out_size) * 7.25
|
|
897
|
+
if not np.isfinite(px) or px <= 0:
|
|
898
|
+
px = 5
|
|
899
|
+
fig.add_trace(go.Scatter(
|
|
900
|
+
x=xv[out_idx], y=yv[out_idx], mode="markers",
|
|
901
|
+
marker=dict(symbol=out_shape, size=px,
|
|
902
|
+
sizemode="diameter",
|
|
903
|
+
color=make_trans(fill0, pt_opacity),
|
|
904
|
+
opacity=1,
|
|
905
|
+
line=dict(color=to_hex(fill0), width=1.5)),
|
|
906
|
+
hoverinfo="x+y", showlegend=False))
|
|
907
|
+
font_sz = max(9, round(float(ID_size) * 14))
|
|
908
|
+
for i in out_idx:
|
|
909
|
+
fig.add_annotation(
|
|
910
|
+
x=xv[i], y=yv[i], text=str(labels[i]),
|
|
911
|
+
showarrow=False, yshift=-12,
|
|
912
|
+
font=dict(color=to_hex(ID_color), size=font_sz,
|
|
913
|
+
family="Arial"))
|
|
914
|
+
|
|
915
|
+
|
|
916
|
+
def _forecast_traces(fig, frcst, ts_PI):
|
|
917
|
+
"""Overlay the forecast on the time series display: model-fit
|
|
918
|
+
line, PI band with dotted boundaries, forecast line + points.
|
|
919
|
+
R analog: plt.plotly.R forecast section (~lines 343-451),
|
|
920
|
+
default-theme forecast hue rgb(.6, 0, 0)."""
|
|
921
|
+
fit_rgba = "rgba(153,0,0,0.35)" # muted: fit and PI lines
|
|
922
|
+
fore_rgba = "rgba(153,0,0,0.90)" # solid: forecast
|
|
923
|
+
band_rgba = "rgba(153,0,0,0.15)" # light: PI band fill
|
|
924
|
+
xf = np.asarray(frcst["x_fit"]) # numpy datetimes: plain
|
|
925
|
+
xh = np.asarray(frcst["x_hat"]) # Timestamps break kaleido
|
|
926
|
+
yf, yh = frcst["y_fit"], frcst["y_hat"]
|
|
927
|
+
fig.add_trace(go.Scatter(
|
|
928
|
+
x=xf, y=yf, mode="lines",
|
|
929
|
+
line={"color": fit_rgba, "width": 1.5},
|
|
930
|
+
name="Model fit", showlegend=True))
|
|
931
|
+
fig.add_trace(go.Scatter( # connector to forecast
|
|
932
|
+
x=np.concatenate([xf[-1:], xh[:1]]),
|
|
933
|
+
y=np.r_[yf[-1], yh[0]], mode="lines",
|
|
934
|
+
line={"color": fit_rgba, "width": 1.5},
|
|
935
|
+
showlegend=False, hoverinfo="skip"))
|
|
936
|
+
fig.add_trace(go.Scatter( # PI band polygon
|
|
937
|
+
x=np.concatenate([xh, xh[::-1]]),
|
|
938
|
+
y=np.concatenate([frcst["y_upr"], frcst["y_lwr"][::-1]]),
|
|
939
|
+
mode="none", fill="toself", fillcolor=band_rgba,
|
|
940
|
+
name=f"{round(ts_PI * 100)}% PI", showlegend=True,
|
|
941
|
+
hoverinfo="skip"))
|
|
942
|
+
for bound in (frcst["y_upr"], frcst["y_lwr"]):
|
|
943
|
+
fig.add_trace(go.Scatter(
|
|
944
|
+
x=xh, y=bound, mode="lines",
|
|
945
|
+
line={"color": fit_rgba, "width": 1, "dash": "dot"},
|
|
946
|
+
showlegend=False))
|
|
947
|
+
fig.add_trace(go.Scatter(
|
|
948
|
+
x=xh, y=yh, mode="lines+markers",
|
|
949
|
+
marker={"symbol": "circle", "size": 6,
|
|
950
|
+
"color": fore_rgba},
|
|
951
|
+
line={"color": fore_rgba, "width": 2},
|
|
952
|
+
name="Forecast", showlegend=True))
|
|
953
|
+
|
|
954
|
+
|
|
955
|
+
def _ts_fitted_lines(frcst, yv, x_name, y_name, ts_unit, digits_d):
|
|
956
|
+
"""Table of the observed y beside the model-fitted y over the
|
|
957
|
+
historical dates, printed when ts_fitted=True. R analog: the
|
|
958
|
+
out_fitted data frame of plt.forecast.R (~590). The classic
|
|
959
|
+
ES fit starts after the first cycle, so x_fit can be a tail of
|
|
960
|
+
the series; the observed column aligns to that tail."""
|
|
961
|
+
dd = 2 if digits_d is None else digits_d
|
|
962
|
+
xf = pd.DatetimeIndex(np.asarray(frcst["x_fit"]))
|
|
963
|
+
yf = np.asarray(frcst["y_fit"], dtype=float)
|
|
964
|
+
y_obs = np.asarray(yv, dtype=float)[-len(yf):]
|
|
965
|
+
dfmt = {"months": "%b %Y", "years": "%Y"}.get(ts_unit)
|
|
966
|
+
if ts_unit == "quarters":
|
|
967
|
+
dates = [f"{d.year} Q{d.quarter}" for d in xf]
|
|
968
|
+
elif dfmt:
|
|
969
|
+
dates = list(xf.strftime(dfmt))
|
|
970
|
+
else:
|
|
971
|
+
dates = list(xf.strftime("%Y-%m-%d"))
|
|
972
|
+
tbl = pd.DataFrame({x_name: dates, y_name: y_obs, "fitted": yf})
|
|
973
|
+
return ["", "Fitted Values", "-" * 13,
|
|
974
|
+
tbl.to_string(
|
|
975
|
+
index=False,
|
|
976
|
+
float_format=lambda v: f"{v:.{dd}f}")]
|
|
977
|
+
|
|
978
|
+
|
|
979
|
+
def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
980
|
+
n_row=None, n_col=None,
|
|
981
|
+
form="scatter", stat=None, sort="0", show_runs=False,
|
|
982
|
+
center_line="off",
|
|
983
|
+
contour_n=None, contour_nbins=None, contour_points=None,
|
|
984
|
+
contour_legend=None,
|
|
985
|
+
smooth_points=100, smooth_size=1, smooth_power=0.25,
|
|
986
|
+
smooth_bins=128,
|
|
987
|
+
enhance=False,
|
|
988
|
+
segments_x=None, segments_y=None,
|
|
989
|
+
add=None, x1=None, y1=None, x2=None, y2=None,
|
|
990
|
+
fill=None, color=None, transparency=None,
|
|
991
|
+
pt_size=1, pt_shape="circle", jitter_x=None, jitter_y=None,
|
|
992
|
+
MD_cut=0, out_cut=0, out_shape="circle-open", out_size=1,
|
|
993
|
+
ID=None, ID_color="gray50", ID_size=0.6,
|
|
994
|
+
fit="off", fit_power=1, fit_se=None, plot_errors=False,
|
|
995
|
+
fit_new=None,
|
|
996
|
+
fit_color=None, fit_lwd=None, span=0.75,
|
|
997
|
+
ellipse=0, ellipse_fill=None, ellipse_color=None,
|
|
998
|
+
ellipse_lwd=None,
|
|
999
|
+
ts_unit=None, ts_agg="sum", ts_stack=False,
|
|
1000
|
+
ts_area_fill=None, ts_area_split=0,
|
|
1001
|
+
ts_ahead=0, ts_method="es",
|
|
1002
|
+
ts_source="fable", ts_error=None,
|
|
1003
|
+
ts_trend=None, ts_seasons=None,
|
|
1004
|
+
ts_alpha=None, ts_beta=None, ts_gamma=None, ts_PI=0.95,
|
|
1005
|
+
ts_NA=None, ts_format=None, ts_fitted=False,
|
|
1006
|
+
ts_n_x_tics=None,
|
|
1007
|
+
axis_fmt="K", axis_x_pre="", axis_y_pre="",
|
|
1008
|
+
rotate_x=0, rotate_y=0, scale_x=None, scale_y=None,
|
|
1009
|
+
xlab=None, ylab=None, main=None, digits_d=None,
|
|
1010
|
+
quiet=None):
|
|
1011
|
+
"""Analytic view of the relationship between two numerical
|
|
1012
|
+
variables, optionally grouped (by=). A date x displays as a
|
|
1013
|
+
time series. Variables are strings naming columns of the
|
|
1014
|
+
DataFrame `data`. Returns a plotly Figure.
|
|
1015
|
+
"""
|
|
1016
|
+
|
|
1017
|
+
# ----- validate parameters ------------------------------------
|
|
1018
|
+
if y is None:
|
|
1019
|
+
raise ValueError(
|
|
1020
|
+
"Argument y is required. For the distribution of a "
|
|
1021
|
+
"single variable, use X().")
|
|
1022
|
+
if data is None:
|
|
1023
|
+
raise ValueError(
|
|
1024
|
+
"data= is required: a pandas DataFrame containing the "
|
|
1025
|
+
"named columns")
|
|
1026
|
+
if form not in _FORMS:
|
|
1027
|
+
raise ValueError(f"form must be one of {_FORMS}")
|
|
1028
|
+
# setting a contour_ parameter selects the contour form, as
|
|
1029
|
+
# in R (XY.R ~174)
|
|
1030
|
+
if any(p is not None for p in
|
|
1031
|
+
(contour_n, contour_nbins, contour_points,
|
|
1032
|
+
contour_legend)):
|
|
1033
|
+
form = "contour"
|
|
1034
|
+
contour_n = 20 if contour_n is None else contour_n
|
|
1035
|
+
contour_nbins = 50 if contour_nbins is None else contour_nbins
|
|
1036
|
+
contour_points = bool(contour_points)
|
|
1037
|
+
contour_legend = bool(contour_legend)
|
|
1038
|
+
if form != "scatter" and by is not None:
|
|
1039
|
+
raise ValueError(
|
|
1040
|
+
'by= is active only for a scatter plot, '
|
|
1041
|
+
'form="scatter"')
|
|
1042
|
+
if add is not None and form in ("contour", "smooth"):
|
|
1043
|
+
raise ValueError(
|
|
1044
|
+
'add is not active for "contour" or "smooth" plots')
|
|
1045
|
+
if add is not None and facet is not None:
|
|
1046
|
+
raise ValueError(
|
|
1047
|
+
"add= annotations apply to a single panel: "
|
|
1048
|
+
"no facet=")
|
|
1049
|
+
if axis_fmt not in ("K", ",", ".", ""):
|
|
1050
|
+
raise ValueError('axis_fmt: "K", ",", ".", or ""')
|
|
1051
|
+
if center_line not in ("off", "mean", "median", "zero"):
|
|
1052
|
+
raise ValueError(
|
|
1053
|
+
'center_line: "off", "mean", "median", or "zero"')
|
|
1054
|
+
if center_line != "off" and facet is not None:
|
|
1055
|
+
raise ValueError(
|
|
1056
|
+
"center_line draws on a single panel: no facet=")
|
|
1057
|
+
if plot_errors:
|
|
1058
|
+
if fit == "off":
|
|
1059
|
+
raise ValueError(
|
|
1060
|
+
"plot_errors draws residual segments to the fit "
|
|
1061
|
+
"line: specify fit=")
|
|
1062
|
+
if form != "scatter":
|
|
1063
|
+
raise ValueError('plot_errors applies to form="scatter"')
|
|
1064
|
+
if facet is not None:
|
|
1065
|
+
raise ValueError(
|
|
1066
|
+
"plot_errors draws on a single panel: no facet=")
|
|
1067
|
+
if (scale_x is not None or scale_y is not None) \
|
|
1068
|
+
and facet is not None:
|
|
1069
|
+
raise ValueError(
|
|
1070
|
+
"scale_x and scale_y apply to a single panel: "
|
|
1071
|
+
"no facet=")
|
|
1072
|
+
if fit not in _FITS:
|
|
1073
|
+
raise ValueError(f"fit must be one of {_FITS}")
|
|
1074
|
+
if fit == "ls": # least squares synonym
|
|
1075
|
+
fit = "lm"
|
|
1076
|
+
if MD_cut > 0 or out_cut > 0:
|
|
1077
|
+
if by is not None or facet is not None:
|
|
1078
|
+
raise ValueError(
|
|
1079
|
+
"outlier flagging (MD_cut/out_cut) applies to a "
|
|
1080
|
+
"single scatterplot: no by= or facet=")
|
|
1081
|
+
if form != "scatter":
|
|
1082
|
+
raise ValueError(
|
|
1083
|
+
"outlier flagging (MD_cut/out_cut) applies to "
|
|
1084
|
+
'the scatter form, form="scatter"')
|
|
1085
|
+
if ts_unit is not None and facet is not None:
|
|
1086
|
+
raise ValueError(
|
|
1087
|
+
"ts_unit does not yet apply to facet plots")
|
|
1088
|
+
if (n_row is not None or n_col is not None) \
|
|
1089
|
+
and facet is None:
|
|
1090
|
+
raise ValueError("n_row and n_col lay out facet panels: "
|
|
1091
|
+
"specify facet=")
|
|
1092
|
+
if ts_agg not in ("sum", "mean"):
|
|
1093
|
+
raise ValueError('ts_agg: "sum" or "mean"')
|
|
1094
|
+
if ts_stack and by is None:
|
|
1095
|
+
raise ValueError(
|
|
1096
|
+
"ts_stack stacks multiple time series: specify by=")
|
|
1097
|
+
if (by is not None and ts_area_fill is not None
|
|
1098
|
+
and not ts_stack):
|
|
1099
|
+
raise ValueError(
|
|
1100
|
+
"Filling the areas under multiple curves is only "
|
|
1101
|
+
"meaningful if the curves are stacked, so set "
|
|
1102
|
+
"ts_stack=True")
|
|
1103
|
+
if ts_ahead > 0:
|
|
1104
|
+
if ts_source not in ("fable", "classic"):
|
|
1105
|
+
raise ValueError('ts_source: "fable" or "classic"')
|
|
1106
|
+
if by is not None:
|
|
1107
|
+
raise ValueError(
|
|
1108
|
+
"Can only forecast a single time series, "
|
|
1109
|
+
"so no by=")
|
|
1110
|
+
if facet is not None:
|
|
1111
|
+
raise ValueError(
|
|
1112
|
+
"Can only forecast a single time series, "
|
|
1113
|
+
"so no facet=")
|
|
1114
|
+
|
|
1115
|
+
if filter is not None:
|
|
1116
|
+
data = data.query(filter)
|
|
1117
|
+
|
|
1118
|
+
# ----- a vector of x (or y) variables: the multi-series
|
|
1119
|
+
# overlay; facet= panels it (the facet-series display) --------
|
|
1120
|
+
x_multi = isinstance(x, (list, tuple))
|
|
1121
|
+
y_multi = isinstance(y, (list, tuple))
|
|
1122
|
+
if x_multi and len(x) == 1:
|
|
1123
|
+
x, x_multi = x[0], False
|
|
1124
|
+
if y_multi and len(y) == 1:
|
|
1125
|
+
y, y_multi = y[0], False
|
|
1126
|
+
if x_multi and y_multi:
|
|
1127
|
+
# the same numeric vector for both x and y is a
|
|
1128
|
+
# scatterplot matrix (R: same.xy.expr); by= and facet=
|
|
1129
|
+
# are unsupported by design, as in R
|
|
1130
|
+
if list(x) != list(y):
|
|
1131
|
+
raise ValueError(
|
|
1132
|
+
"vectors for both x and y form a scatterplot "
|
|
1133
|
+
"matrix: specify the same variables for x and y")
|
|
1134
|
+
if by is not None or facet is not None:
|
|
1135
|
+
raise ValueError(
|
|
1136
|
+
"a scatterplot matrix does not support by= or "
|
|
1137
|
+
"facet=: its small points make group colors hard "
|
|
1138
|
+
"to distinguish, omitted by design")
|
|
1139
|
+
mat_df = data[list(x)].apply(
|
|
1140
|
+
pd.to_numeric, errors="coerce")
|
|
1141
|
+
return scatter_matrix(
|
|
1142
|
+
mat_df, fit=("off" if fit in ("off", "null")
|
|
1143
|
+
else fit),
|
|
1144
|
+
digits_d=digits_d, main=main)
|
|
1145
|
+
if x_multi or y_multi:
|
|
1146
|
+
return _apply_rotate(_xy_series(
|
|
1147
|
+
x, y, data, x_multi, by, facet, form, n_row, n_col,
|
|
1148
|
+
fill, transparency, pt_shape, pt_size,
|
|
1149
|
+
fit, fit_power, fit_se, fit_lwd, span,
|
|
1150
|
+
ellipse, ellipse_fill, ellipse_lwd,
|
|
1151
|
+
MD_cut, out_cut,
|
|
1152
|
+
xlab, ylab, main, digits_d, quiet),
|
|
1153
|
+
rotate_x, rotate_y)
|
|
1154
|
+
|
|
1155
|
+
# ----- row_names: the data-frame row labels as a categorical
|
|
1156
|
+
# axis -> a Cleveland dot plot, delegated to Chart(form="dot")
|
|
1157
|
+
# (categorical variables live in Chart), the numeric variable
|
|
1158
|
+
# its value ------------------------------------------------------
|
|
1159
|
+
_ROW_KW = ("row_names", "row.names")
|
|
1160
|
+
x_row = isinstance(x, str) and x in _ROW_KW
|
|
1161
|
+
y_row = isinstance(y, str) and y in _ROW_KW
|
|
1162
|
+
if x_row or y_row:
|
|
1163
|
+
from .Chart import Chart
|
|
1164
|
+
val = y if x_row else x
|
|
1165
|
+
if (not isinstance(val, str) or val in _ROW_KW
|
|
1166
|
+
or not pd.api.types.is_numeric_dtype(
|
|
1167
|
+
get_column(data, val, "x" if x_row else "y"))):
|
|
1168
|
+
raise ValueError(
|
|
1169
|
+
"row_names pairs with one numerical variable")
|
|
1170
|
+
names = data.index.astype(str)
|
|
1171
|
+
d2 = data.copy()
|
|
1172
|
+
d2["row_names"] = pd.Categorical(
|
|
1173
|
+
names, categories=list(dict.fromkeys(names)))
|
|
1174
|
+
# no by= here, so a single scatterplot fill (XY's pt_color
|
|
1175
|
+
# default) rather than Chart's per-category dot hues
|
|
1176
|
+
dot_fill = (get_option("pt_color", "#324E5C")
|
|
1177
|
+
if fill is None else fill)
|
|
1178
|
+
# XY/lessR names droplines by the axis they reach (segments_y
|
|
1179
|
+
# = to the y axis, i.e. horizontal); Chart names them by
|
|
1180
|
+
# direction (segments_x = horizontal) -- so they swap
|
|
1181
|
+
return Chart(
|
|
1182
|
+
"row_names", y=val, data=d2, form="dot", horiz=y_row,
|
|
1183
|
+
sort=sort, segments_x=segments_y, segments_y=segments_x,
|
|
1184
|
+
fill=dot_fill, color=color, pt_size=pt_size, main=main,
|
|
1185
|
+
xlab=xlab, ylab=ylab, digits_d=digits_d,
|
|
1186
|
+
rotate_x=rotate_x, rotate_y=rotate_y,
|
|
1187
|
+
axis_fmt=axis_fmt, quiet=quiet)
|
|
1188
|
+
|
|
1189
|
+
# ----- resolve variables and filter ---------------------------
|
|
1190
|
+
# ".Index" is the row-number pseudo-variable (1..n) for a run
|
|
1191
|
+
# chart: synthesize it rather than looking up a column
|
|
1192
|
+
def _resolve(name, arg):
|
|
1193
|
+
if isinstance(name, str) and name == ".Index":
|
|
1194
|
+
return pd.Series(range(1, len(data) + 1),
|
|
1195
|
+
index=data.index, name="Index")
|
|
1196
|
+
return get_column(data, name, arg)
|
|
1197
|
+
|
|
1198
|
+
index_x = isinstance(x, str) and x == ".Index"
|
|
1199
|
+
index_y = isinstance(y, str) and y == ".Index"
|
|
1200
|
+
x_ser = _resolve(x, "x")
|
|
1201
|
+
y_ser = _resolve(y, "y")
|
|
1202
|
+
if index_x: # for axis labels below
|
|
1203
|
+
x = "Index"
|
|
1204
|
+
if index_y:
|
|
1205
|
+
y = "Index"
|
|
1206
|
+
|
|
1207
|
+
# stat= aggregates a numerical variable by a categorical one and
|
|
1208
|
+
# shows a Cleveland dot plot -- delegate to Chart(form="dot"),
|
|
1209
|
+
# its tested renderer (categorical variables live in Chart)
|
|
1210
|
+
if stat is not None:
|
|
1211
|
+
from .Chart import Chart
|
|
1212
|
+
x_num = pd.api.types.is_numeric_dtype(x_ser)
|
|
1213
|
+
y_num = pd.api.types.is_numeric_dtype(y_ser)
|
|
1214
|
+
common = dict(
|
|
1215
|
+
data=data, by=by, facet=facet, form="dot", stat=stat,
|
|
1216
|
+
sort=sort, fill=fill, color=color,
|
|
1217
|
+
transparency=transparency, pt_size=pt_size, main=main,
|
|
1218
|
+
digits_d=digits_d, rotate_x=rotate_x, rotate_y=rotate_y,
|
|
1219
|
+
axis_fmt=axis_fmt, quiet=quiet)
|
|
1220
|
+
if not x_num and y_num: # categorical x, value y
|
|
1221
|
+
return Chart(x, y=y, xlab=xlab, ylab=ylab, **common)
|
|
1222
|
+
if x_num and not y_num: # value x, categorical y
|
|
1223
|
+
return Chart(y, y=x, horiz=True, xlab=xlab, ylab=ylab,
|
|
1224
|
+
**common)
|
|
1225
|
+
raise ValueError(
|
|
1226
|
+
"XY() with stat= needs one categorical and one "
|
|
1227
|
+
"numerical variable (a Cleveland dot plot)")
|
|
1228
|
+
by_ser = get_column(data, by, "by") if by is not None else None
|
|
1229
|
+
(facet_ser, facet_name,
|
|
1230
|
+
facet2_ser, facet2_name) = resolve_facet(data, facet, "XY")
|
|
1231
|
+
|
|
1232
|
+
# facet needs a categorical variable; a numeric one with more
|
|
1233
|
+
# than 25 distinct values is almost surely continuous, so stop
|
|
1234
|
+
# and point to a category conversion (XY.R ~1168). A category
|
|
1235
|
+
# dtype bypasses this, as an R factor does.
|
|
1236
|
+
for fs, fn in ((facet_ser, facet_name),
|
|
1237
|
+
(facet2_ser, facet2_name)):
|
|
1238
|
+
if (fs is not None
|
|
1239
|
+
and pd.api.types.is_numeric_dtype(fs)
|
|
1240
|
+
and fs.nunique() > 25):
|
|
1241
|
+
raise ValueError(
|
|
1242
|
+
f"Parameter facet requires a categorical variable, "
|
|
1243
|
+
f"but '{fn}' is numeric with {fs.nunique()} unique "
|
|
1244
|
+
f"values.\n\nIf '{fn}' is categorical, convert it to "
|
|
1245
|
+
f"a category:\n "
|
|
1246
|
+
f"data['{fn}'] = data['{fn}'].astype('category')")
|
|
1247
|
+
|
|
1248
|
+
# a string date column (e.g. "2024-01-15", "2024 Q3") becomes
|
|
1249
|
+
# datetimes, so it drives the time-series display, as XY.R runs
|
|
1250
|
+
# date.infer on x. Non-date strings pass through unchanged.
|
|
1251
|
+
# ts_format= parses x with an explicit strftime pattern (XY.R
|
|
1252
|
+
# ~716), for a format date_infer would not recognize.
|
|
1253
|
+
if (ts_format is not None
|
|
1254
|
+
and not pd.api.types.is_datetime64_any_dtype(x_ser)):
|
|
1255
|
+
parsed = pd.to_datetime(x_ser, format=ts_format,
|
|
1256
|
+
errors="raise")
|
|
1257
|
+
parsed.index, parsed.name = x_ser.index, x_ser.name
|
|
1258
|
+
x_ser = parsed
|
|
1259
|
+
elif (not pd.api.types.is_numeric_dtype(x_ser)
|
|
1260
|
+
and not pd.api.types.is_datetime64_any_dtype(x_ser)):
|
|
1261
|
+
try:
|
|
1262
|
+
parsed = date_infer(x_ser)
|
|
1263
|
+
if pd.api.types.is_datetime64_any_dtype(parsed):
|
|
1264
|
+
parsed.index = x_ser.index
|
|
1265
|
+
parsed.name = x_ser.name
|
|
1266
|
+
x_ser = parsed
|
|
1267
|
+
except (ValueError, TypeError): # not a date column
|
|
1268
|
+
pass
|
|
1269
|
+
|
|
1270
|
+
is_date = pd.api.types.is_datetime64_any_dtype(x_ser)
|
|
1271
|
+
# a categorical/continuous mix with facet= is X()'s display:
|
|
1272
|
+
# redirect, as XY.R's Trellis check does
|
|
1273
|
+
if facet is not None and not is_date:
|
|
1274
|
+
x_num = pd.api.types.is_numeric_dtype(x_ser)
|
|
1275
|
+
y_num = pd.api.types.is_numeric_dtype(y_ser)
|
|
1276
|
+
if x_num != y_num:
|
|
1277
|
+
cont, cat_ = (x, y) if x_num else (y, x)
|
|
1278
|
+
f_txt = ("[" + ", ".join(f"'{f}'" for f in facet)
|
|
1279
|
+
+ "]"
|
|
1280
|
+
if isinstance(facet, (list, tuple))
|
|
1281
|
+
else f"'{facet}'" if isinstance(facet, str)
|
|
1282
|
+
else f"'{facet_name}'")
|
|
1283
|
+
raise TypeError(
|
|
1284
|
+
"XY() requires a continuous x and a continuous "
|
|
1285
|
+
"y.\nFor a faceted distribution of the "
|
|
1286
|
+
f"continuous {cont} across the levels\nof the "
|
|
1287
|
+
f"categorical {cat_}, use X() with a by "
|
|
1288
|
+
f"variable:\n X('{cont}', by='{cat_}', "
|
|
1289
|
+
f"facet={f_txt})")
|
|
1290
|
+
for nm, s in ((x, x_ser), (y, y_ser)):
|
|
1291
|
+
if s is x_ser and is_date:
|
|
1292
|
+
continue
|
|
1293
|
+
if not pd.api.types.is_numeric_dtype(s):
|
|
1294
|
+
raise TypeError(
|
|
1295
|
+
f"XY() analyzes the relationship of two numerical "
|
|
1296
|
+
f"variables, but '{nm}' is {s.dtype}. For a "
|
|
1297
|
+
"categorical variable use Chart().")
|
|
1298
|
+
|
|
1299
|
+
# ts_NA= replaces missing y with a set value before the NA
|
|
1300
|
+
# rows are dropped, so a gap in the series becomes that value
|
|
1301
|
+
# (e.g. 0) rather than a hole (XY.R ~908)
|
|
1302
|
+
if ts_NA is not None:
|
|
1303
|
+
y_ser = y_ser.fillna(ts_NA)
|
|
1304
|
+
|
|
1305
|
+
used = [s for s in (x_ser, y_ser, by_ser, facet_ser,
|
|
1306
|
+
facet2_ser)
|
|
1307
|
+
if s is not None]
|
|
1308
|
+
keep = ~pd.concat(used, axis=1).isna().any(axis=1)
|
|
1309
|
+
x_ser, y_ser = x_ser[keep], y_ser[keep]
|
|
1310
|
+
if by_ser is not None:
|
|
1311
|
+
by_ser = by_ser[keep]
|
|
1312
|
+
if facet_ser is not None:
|
|
1313
|
+
facet_ser = facet_ser[keep]
|
|
1314
|
+
if facet2_ser is not None:
|
|
1315
|
+
facet2_ser = facet2_ser[keep]
|
|
1316
|
+
|
|
1317
|
+
if is_date and form != "scatter":
|
|
1318
|
+
raise ValueError(
|
|
1319
|
+
f'form="{form}" describes the joint density of two '
|
|
1320
|
+
"numerical variables, not a time series")
|
|
1321
|
+
|
|
1322
|
+
if is_date: # time series: date order
|
|
1323
|
+
od = x_ser.sort_values(kind="stable").index
|
|
1324
|
+
x_ser, y_ser = x_ser.loc[od], y_ser.loc[od]
|
|
1325
|
+
if by_ser is not None:
|
|
1326
|
+
by_ser = by_ser.loc[od]
|
|
1327
|
+
if facet_ser is not None:
|
|
1328
|
+
facet_ser = facet_ser.loc[od]
|
|
1329
|
+
if facet2_ser is not None:
|
|
1330
|
+
facet2_ser = facet2_ser.loc[od]
|
|
1331
|
+
if fit != "off" or (ellipse is not False and ellipse):
|
|
1332
|
+
raise NotImplementedError(
|
|
1333
|
+
"fit and ellipse for a time series (date x) are "
|
|
1334
|
+
"not yet ported")
|
|
1335
|
+
if MD_cut > 0 or out_cut > 0:
|
|
1336
|
+
raise ValueError(
|
|
1337
|
+
"outlier flagging (MD_cut/out_cut) applies to a "
|
|
1338
|
+
"scatterplot, not a time series")
|
|
1339
|
+
elif ts_ahead > 0:
|
|
1340
|
+
raise ValueError(
|
|
1341
|
+
"forecasting (ts_ahead) requires a date variable "
|
|
1342
|
+
"for x")
|
|
1343
|
+
|
|
1344
|
+
if not is_date and (ts_stack or ts_area_fill is not None):
|
|
1345
|
+
raise ValueError(
|
|
1346
|
+
"ts_stack and ts_area_fill apply to a time series "
|
|
1347
|
+
"(date x)")
|
|
1348
|
+
|
|
1349
|
+
# ----- ts aggregation (ts_unit/ts_agg) --------------------------
|
|
1350
|
+
if is_date and ts_unit is not None:
|
|
1351
|
+
x_ser, y_ser, by_ser, ts_unit = plt_time(
|
|
1352
|
+
x_ser, y_ser, by_ser, ts_unit, ts_agg,
|
|
1353
|
+
quiet=resolve_quiet(quiet))
|
|
1354
|
+
if is_date and (ts_stack or ts_area_fill is not None) \
|
|
1355
|
+
and pt_size == 1:
|
|
1356
|
+
pt_size = 0 # R: no points with areas
|
|
1357
|
+
|
|
1358
|
+
# ----- enhance: the enhanced-scatterplot bundle ----------------
|
|
1359
|
+
# R XY.R:445-450 — for parameters not explicitly set:
|
|
1360
|
+
# ellipse .95, MD_cut 6, fit "lm", the mean crosshair
|
|
1361
|
+
# (add="means"); default values stand in for R's missing().
|
|
1362
|
+
# MD flagging only where supported (single-panel scatter);
|
|
1363
|
+
# the crosshair follows R's annotation path (scatter and
|
|
1364
|
+
# smooth, single panel).
|
|
1365
|
+
add_means = False
|
|
1366
|
+
if enhance:
|
|
1367
|
+
if is_date:
|
|
1368
|
+
raise ValueError(
|
|
1369
|
+
"enhance applies to a scatterplot, not a time "
|
|
1370
|
+
"series")
|
|
1371
|
+
if not ellipse:
|
|
1372
|
+
ellipse = 0.95
|
|
1373
|
+
if fit == "off":
|
|
1374
|
+
fit = "lm"
|
|
1375
|
+
if (MD_cut == 0 and out_cut == 0 and form == "scatter"
|
|
1376
|
+
and by is None and facet is None):
|
|
1377
|
+
MD_cut = 6
|
|
1378
|
+
add_means = (form in ("scatter", "smooth")
|
|
1379
|
+
and facet is None)
|
|
1380
|
+
|
|
1381
|
+
# ----- facet: one panel per level -----------------------------
|
|
1382
|
+
if facet is not None:
|
|
1383
|
+
facet2_arr = f2_order = None
|
|
1384
|
+
if facet2_ser is not None:
|
|
1385
|
+
facet2_arr = facet2_ser.to_numpy()
|
|
1386
|
+
f2_order = category_order(facet2_ser)
|
|
1387
|
+
# near-square grid by default, like R's lattice facets;
|
|
1388
|
+
# explicit n_col/n_row win, and a date (time series) keeps
|
|
1389
|
+
# one wide panel per row. With facet2 the grid shape is set
|
|
1390
|
+
# from the facet1 levels in facet_panels, so this is moot.
|
|
1391
|
+
n_lvl = len(category_order(facet_ser))
|
|
1392
|
+
if n_col is not None:
|
|
1393
|
+
n_col_use = int(n_col)
|
|
1394
|
+
elif n_row is not None:
|
|
1395
|
+
n_col_use = math.ceil(n_lvl / int(n_row))
|
|
1396
|
+
elif is_date:
|
|
1397
|
+
n_col_use = 1
|
|
1398
|
+
else:
|
|
1399
|
+
n_col_use = math.ceil(math.sqrt(n_lvl))
|
|
1400
|
+
if form in ("contour", "smooth"):
|
|
1401
|
+
if ellipse is True:
|
|
1402
|
+
ellipse = 0.95
|
|
1403
|
+
if not resolve_quiet(quiet):
|
|
1404
|
+
print("\n".join(xy_stats(
|
|
1405
|
+
[(None, x_ser.to_numpy(dtype=float),
|
|
1406
|
+
y_ser.to_numpy(dtype=float))], x, y,
|
|
1407
|
+
digits_d=2 if digits_d is None
|
|
1408
|
+
else digits_d)))
|
|
1409
|
+
return _apply_rotate(plt_contour_facet(
|
|
1410
|
+
x_ser.to_numpy(dtype=float),
|
|
1411
|
+
y_ser.to_numpy(dtype=float),
|
|
1412
|
+
facet_ser.to_numpy(),
|
|
1413
|
+
category_order(facet_ser), facet_name,
|
|
1414
|
+
facet2_arr, f2_order, facet2_name,
|
|
1415
|
+
contour_n, contour_nbins, contour_points,
|
|
1416
|
+
pt_size,
|
|
1417
|
+
x if xlab is None else xlab,
|
|
1418
|
+
y if ylab is None else ylab,
|
|
1419
|
+
main, 2 if digits_d is None else digits_d,
|
|
1420
|
+
ellipse,
|
|
1421
|
+
(get_option("ellipse_color", "gray20")
|
|
1422
|
+
if ellipse_color is None else ellipse_color),
|
|
1423
|
+
(get_option("ellipse_lwd", 1)
|
|
1424
|
+
if ellipse_lwd is None else ellipse_lwd),
|
|
1425
|
+
fit, fit_power,
|
|
1426
|
+
(get_option("fit_color", "#5C4032")
|
|
1427
|
+
if fit_color is None else fit_color),
|
|
1428
|
+
(get_option("fit_lwd", 2)
|
|
1429
|
+
if fit_lwd is None else fit_lwd),
|
|
1430
|
+
_plt_fit, _ellipse_region,
|
|
1431
|
+
render=form, smooth_power=smooth_power,
|
|
1432
|
+
smooth_points=smooth_points,
|
|
1433
|
+
smooth_bins=smooth_bins,
|
|
1434
|
+
n_col=n_col_use, axis_fmt=axis_fmt,
|
|
1435
|
+
axis_x_pre=axis_x_pre, axis_y_pre=axis_y_pre),
|
|
1436
|
+
rotate_x, rotate_y)
|
|
1437
|
+
if is_date: # time-series panels
|
|
1438
|
+
if by is not None:
|
|
1439
|
+
raise NotImplementedError(
|
|
1440
|
+
"by= with facet= for a time series is not "
|
|
1441
|
+
"yet ported")
|
|
1442
|
+
if transparency is None:
|
|
1443
|
+
transparency = get_option("trans_pt_fill", 0.10)
|
|
1444
|
+
fill0 = (get_option("pt_color", "#324E5C")
|
|
1445
|
+
if fill is None else
|
|
1446
|
+
(fill[0] if isinstance(fill, (list, tuple))
|
|
1447
|
+
else fill))
|
|
1448
|
+
border0 = fill0 if color is None else color
|
|
1449
|
+
a_fill = (None if ts_area_fill is None else
|
|
1450
|
+
_area_color(ts_area_fill,
|
|
1451
|
+
1 - transparency))
|
|
1452
|
+
ts_fig = _ts_facet(
|
|
1453
|
+
x_ser.to_numpy(),
|
|
1454
|
+
y_ser.to_numpy(dtype=float),
|
|
1455
|
+
facet_ser.to_numpy(), category_order(facet_ser),
|
|
1456
|
+
fill0, border0, pt_size, 1 - transparency,
|
|
1457
|
+
x if xlab is None else xlab,
|
|
1458
|
+
y if ylab is None else ylab,
|
|
1459
|
+
main, 2 if digits_d is None else digits_d,
|
|
1460
|
+
area_fill=a_fill,
|
|
1461
|
+
area_split=float(ts_area_split),
|
|
1462
|
+
facet_name=facet_name, facet2_arr=facet2_arr,
|
|
1463
|
+
facet2_order=f2_order,
|
|
1464
|
+
facet2_name=facet2_name, n_col=n_col_use)
|
|
1465
|
+
if ts_n_x_tics is not None: # date-axis tick count
|
|
1466
|
+
ts_fig.update_xaxes(nticks=int(ts_n_x_tics))
|
|
1467
|
+
return _apply_rotate(ts_fig, rotate_x, rotate_y)
|
|
1468
|
+
by_arr = by_order = None
|
|
1469
|
+
if by_ser is not None:
|
|
1470
|
+
by_arr = by_ser.to_numpy()
|
|
1471
|
+
by_order = category_order(by_ser)
|
|
1472
|
+
n_grp = 1 if by_order is None else len(by_order)
|
|
1473
|
+
if fill is None:
|
|
1474
|
+
fills_f = ([get_option("pt_color", "#324E5C")]
|
|
1475
|
+
if n_grp == 1
|
|
1476
|
+
else [BASE_COLORS[i % len(BASE_COLORS)]
|
|
1477
|
+
for i in range(n_grp)])
|
|
1478
|
+
else:
|
|
1479
|
+
fills_f = (list(fill)
|
|
1480
|
+
if isinstance(fill, (list, tuple))
|
|
1481
|
+
else [fill])
|
|
1482
|
+
fills_f = [fills_f[i % len(fills_f)]
|
|
1483
|
+
for i in range(n_grp)]
|
|
1484
|
+
if transparency is None:
|
|
1485
|
+
transparency = get_option("trans_pt_fill", 0.10)
|
|
1486
|
+
if fit_se is None:
|
|
1487
|
+
fit_se = 0.95 if by is None else 0
|
|
1488
|
+
se_lv = [lv for lv in (
|
|
1489
|
+
fit_se if isinstance(fit_se, (list, tuple))
|
|
1490
|
+
else [fit_se]) if lv]
|
|
1491
|
+
if ellipse is True:
|
|
1492
|
+
ellipse = 0.95
|
|
1493
|
+
if not resolve_quiet(quiet): # overall relationship
|
|
1494
|
+
dd_s = 2 if digits_d is None else digits_d
|
|
1495
|
+
xv_s = x_ser.to_numpy(dtype=float)
|
|
1496
|
+
print("\n".join(xy_stats(
|
|
1497
|
+
[(None, xv_s, y_ser.to_numpy(dtype=float))],
|
|
1498
|
+
x, y, digits_d=dd_s)))
|
|
1499
|
+
# summary table per grouping variable, ~ XY.R's
|
|
1500
|
+
# .vbs_summary_table pivot block
|
|
1501
|
+
print(f"\n---------- Summary Statistics for {x}")
|
|
1502
|
+
if by_ser is not None:
|
|
1503
|
+
print()
|
|
1504
|
+
print(facet_summary(xv_s, by_ser.to_numpy(),
|
|
1505
|
+
by, dd_s))
|
|
1506
|
+
print()
|
|
1507
|
+
print(facet_summary(xv_s, facet_ser.to_numpy(),
|
|
1508
|
+
facet_name, dd_s))
|
|
1509
|
+
if facet2_ser is not None:
|
|
1510
|
+
print()
|
|
1511
|
+
print(facet_summary(xv_s,
|
|
1512
|
+
facet2_ser.to_numpy(),
|
|
1513
|
+
facet2_name, dd_s))
|
|
1514
|
+
return _apply_rotate(_xy_facet(
|
|
1515
|
+
x_ser.to_numpy(dtype=float),
|
|
1516
|
+
y_ser.to_numpy(dtype=float),
|
|
1517
|
+
by_arr, by_order,
|
|
1518
|
+
facet_ser.to_numpy(), category_order(facet_ser),
|
|
1519
|
+
by, facet_name, fills_f, pt_shape, pt_size,
|
|
1520
|
+
1 - transparency, fit, fit_power, se_lv, span,
|
|
1521
|
+
(get_option("fit_color", "#5C4032")
|
|
1522
|
+
if fit_color is None else fit_color),
|
|
1523
|
+
(get_option("fit_lwd", 2)
|
|
1524
|
+
if fit_lwd is None else fit_lwd),
|
|
1525
|
+
get_option("se_fill", "#1A1A1A19"),
|
|
1526
|
+
ellipse,
|
|
1527
|
+
(get_option("ellipse_fill", "#92806F28")
|
|
1528
|
+
if ellipse_fill is None else ellipse_fill),
|
|
1529
|
+
(get_option("ellipse_color", "gray20")
|
|
1530
|
+
if ellipse_color is None else ellipse_color),
|
|
1531
|
+
(get_option("ellipse_lwd", 1)
|
|
1532
|
+
if ellipse_lwd is None else ellipse_lwd),
|
|
1533
|
+
x if xlab is None else xlab,
|
|
1534
|
+
y if ylab is None else ylab,
|
|
1535
|
+
main, 2 if digits_d is None else digits_d,
|
|
1536
|
+
facet2_arr=facet2_arr, facet2_order=f2_order,
|
|
1537
|
+
facet2_name=facet2_name, n_col=n_col_use),
|
|
1538
|
+
rotate_x, rotate_y)
|
|
1539
|
+
|
|
1540
|
+
# ----- groups and colors --------------------------------------
|
|
1541
|
+
xv = x_ser.to_numpy() if is_date \
|
|
1542
|
+
else x_ser.to_numpy(dtype=float)
|
|
1543
|
+
yv = y_ser.to_numpy(dtype=float)
|
|
1544
|
+
if by_ser is None:
|
|
1545
|
+
groups = [(None, xv, yv)]
|
|
1546
|
+
else:
|
|
1547
|
+
by_order = category_order(by_ser)
|
|
1548
|
+
bv = by_ser.to_numpy()
|
|
1549
|
+
groups = [(nm, xv[bv == nm], yv[bv == nm])
|
|
1550
|
+
for nm in by_order]
|
|
1551
|
+
|
|
1552
|
+
# ----- jitter: the display coordinates only --------------------
|
|
1553
|
+
# auto-jitter an axis with few discrete values (R plt.main.R
|
|
1554
|
+
# ~634-659); the fit, ellipse, and statistics use the
|
|
1555
|
+
# original data, as R restores the un-jittered values
|
|
1556
|
+
do_jitter = (form == "scatter" and not is_date
|
|
1557
|
+
and float(pt_size) > 0)
|
|
1558
|
+
if do_jitter:
|
|
1559
|
+
if jitter_x is None:
|
|
1560
|
+
jitter_x = (float(np.ptp(xv)) / 32
|
|
1561
|
+
if len(np.unique(xv)) <= 14 < len(xv)
|
|
1562
|
+
else 0)
|
|
1563
|
+
if jitter_y is None:
|
|
1564
|
+
jitter_y = (float(np.ptp(yv)) / 32
|
|
1565
|
+
if len(np.unique(yv)) <= 14 < len(yv)
|
|
1566
|
+
else 0)
|
|
1567
|
+
jitter_x = 0 if jitter_x is None else float(jitter_x)
|
|
1568
|
+
jitter_y = 0 if jitter_y is None else float(jitter_y)
|
|
1569
|
+
|
|
1570
|
+
disp_groups = groups
|
|
1571
|
+
if do_jitter and (jitter_x > 0 or jitter_y > 0):
|
|
1572
|
+
rng = np.random.default_rng()
|
|
1573
|
+
xd, yd = xv, yv
|
|
1574
|
+
if jitter_x > 0:
|
|
1575
|
+
xd = xv + rng.uniform(-jitter_x, jitter_x, len(xv))
|
|
1576
|
+
if jitter_y > 0:
|
|
1577
|
+
yd = yv + rng.uniform(-jitter_y, jitter_y, len(yv))
|
|
1578
|
+
if by_ser is None:
|
|
1579
|
+
disp_groups = [(None, xd, yd)]
|
|
1580
|
+
else:
|
|
1581
|
+
disp_groups = [(nm, xd[bv == nm], yd[bv == nm])
|
|
1582
|
+
for nm in by_order]
|
|
1583
|
+
|
|
1584
|
+
# ----- outliers by Mahalanobis distance -----------------------
|
|
1585
|
+
out_idx = md_lines = md_ids = None
|
|
1586
|
+
if (MD_cut > 0 or out_cut > 0) and not is_date:
|
|
1587
|
+
md_ids = np.asarray(
|
|
1588
|
+
get_column(data, ID, "ID")[keep] if ID is not None
|
|
1589
|
+
else x_ser.index).astype(str)
|
|
1590
|
+
out_idx, md_lines = md_outliers(xv, yv, md_ids,
|
|
1591
|
+
MD_cut, out_cut)
|
|
1592
|
+
|
|
1593
|
+
n_grp = len(groups)
|
|
1594
|
+
if fill is None:
|
|
1595
|
+
fills = ([get_option("pt_color", "#324E5C")] if n_grp == 1
|
|
1596
|
+
else [BASE_COLORS[i % len(BASE_COLORS)]
|
|
1597
|
+
for i in range(n_grp)])
|
|
1598
|
+
else:
|
|
1599
|
+
fills = list(fill) if isinstance(fill, (list, tuple)) \
|
|
1600
|
+
else [fill]
|
|
1601
|
+
borders = None if color is None else color
|
|
1602
|
+
if transparency is None:
|
|
1603
|
+
transparency = get_option("trans_pt_fill", 0.10)
|
|
1604
|
+
|
|
1605
|
+
# ----- ts stacked / area fills ---------------------------------
|
|
1606
|
+
# R plt.main.R: stack polygons (~727-755) between cumulative
|
|
1607
|
+
# curves, hues by default, lines in the same hues on top;
|
|
1608
|
+
# single-series area (~678-680) fills toward ts_area_split
|
|
1609
|
+
# (the lattice origin), clipped to the data extent
|
|
1610
|
+
area_polys = []
|
|
1611
|
+
area_yrange = None
|
|
1612
|
+
if ts_stack:
|
|
1613
|
+
x0 = groups[0][1]
|
|
1614
|
+
for nm, xg, yg in groups[1:]:
|
|
1615
|
+
if len(xg) != len(x0) or (xg != x0).any():
|
|
1616
|
+
raise ValueError(
|
|
1617
|
+
"ts_stack requires the same dates at every "
|
|
1618
|
+
"level of by=")
|
|
1619
|
+
if ts_area_fill is not None:
|
|
1620
|
+
a_base = (list(ts_area_fill)
|
|
1621
|
+
if isinstance(ts_area_fill, (list, tuple))
|
|
1622
|
+
else [ts_area_fill])
|
|
1623
|
+
a_cols = [a_base[i % len(a_base)]
|
|
1624
|
+
for i in range(n_grp)]
|
|
1625
|
+
else: # qualitative hues, as R
|
|
1626
|
+
a_cols = [fills[i % len(fills)]
|
|
1627
|
+
for i in range(n_grp)]
|
|
1628
|
+
cum = None
|
|
1629
|
+
stacked = []
|
|
1630
|
+
for i, (nm, xg, yg) in enumerate(groups):
|
|
1631
|
+
yc = yg if cum is None else yg + cum
|
|
1632
|
+
if cum is None: # fill to the series min
|
|
1633
|
+
lo = float(yc.min())
|
|
1634
|
+
xx = np.concatenate([xg, xg[-1:], xg[:1]])
|
|
1635
|
+
yy = np.concatenate([yc, [lo, lo]])
|
|
1636
|
+
else: # band between the curves
|
|
1637
|
+
xx = np.concatenate([xg, xg[::-1]])
|
|
1638
|
+
yy = np.concatenate([yc, cum[::-1]])
|
|
1639
|
+
area_polys.append(
|
|
1640
|
+
(xx, yy, _area_color(a_cols[i],
|
|
1641
|
+
1 - transparency)))
|
|
1642
|
+
stacked.append((nm, xg, yc))
|
|
1643
|
+
cum = yc
|
|
1644
|
+
groups = stacked
|
|
1645
|
+
disp_groups = stacked
|
|
1646
|
+
elif is_date and ts_area_fill is not None:
|
|
1647
|
+
nm0, xg, yg = groups[0]
|
|
1648
|
+
lo, hi = float(yg.min()), float(yg.max())
|
|
1649
|
+
pad_a = 0.04 * (hi - lo) if hi > lo else 1.0
|
|
1650
|
+
base = min(max(float(ts_area_split), lo - pad_a),
|
|
1651
|
+
hi + pad_a)
|
|
1652
|
+
xx = np.concatenate([xg, xg[-1:], xg[:1]])
|
|
1653
|
+
yy = np.concatenate([yg, [base, base]])
|
|
1654
|
+
area_polys.append(
|
|
1655
|
+
(xx, yy, _area_color(ts_area_fill,
|
|
1656
|
+
1 - transparency)))
|
|
1657
|
+
# fill reaches the axis edge, as R fills to the y min
|
|
1658
|
+
area_yrange = [min(base, lo - pad_a), hi + pad_a]
|
|
1659
|
+
|
|
1660
|
+
# ----- fit lines and SE bands ---------------------------------
|
|
1661
|
+
# with by= the bands default off, as in R (XY.R line ~484);
|
|
1662
|
+
# plot_errors shows residuals, not a band, so it too defaults
|
|
1663
|
+
# the band off (XY.R line ~483)
|
|
1664
|
+
if fit_se is None:
|
|
1665
|
+
fit_se = 0 if plot_errors else (0.95 if by is None else 0)
|
|
1666
|
+
se_levels = [lv for lv in (fit_se if isinstance(
|
|
1667
|
+
fit_se, (list, tuple)) else [fit_se]) if lv]
|
|
1668
|
+
if form == "contour": # R: no SE bands on contour
|
|
1669
|
+
se_levels = []
|
|
1670
|
+
|
|
1671
|
+
fit_lines, se_polys, fit_stats = [], [], []
|
|
1672
|
+
err_lines = [] # plot_errors residual segments
|
|
1673
|
+
if fit != "off":
|
|
1674
|
+
for nm, xg, yg in groups:
|
|
1675
|
+
if len(xg) < 2:
|
|
1676
|
+
continue
|
|
1677
|
+
if fit == "loess": # R: bands for lm and loess
|
|
1678
|
+
xs, ys_s, f, se_f = _loess(xg, yg, span)
|
|
1679
|
+
fit_lines.append({"name": nm, "x": xs, "y": f})
|
|
1680
|
+
fit_stats.append((nm, fit, ys_s, f))
|
|
1681
|
+
if plot_errors:
|
|
1682
|
+
err_lines.append((xs, ys_s, f))
|
|
1683
|
+
for lv in se_levels:
|
|
1684
|
+
tq = sps.t.ppf((1 + lv) / 2, len(xs) - 1)
|
|
1685
|
+
se_polys.append((
|
|
1686
|
+
np.concatenate([xs, xs[::-1]]),
|
|
1687
|
+
np.concatenate([f + tq * se_f,
|
|
1688
|
+
(f - tq * se_f)[::-1]])))
|
|
1689
|
+
continue
|
|
1690
|
+
xs, ys_s, f = _plt_fit(xg, yg, fit, fit_power)
|
|
1691
|
+
okf = np.isfinite(f) # exp/log back-transform NaN
|
|
1692
|
+
fit_lines.append({"name": nm, "x": xs[okf],
|
|
1693
|
+
"y": f[okf]})
|
|
1694
|
+
fit_stats.append((nm, fit, ys_s[okf], f[okf]))
|
|
1695
|
+
if plot_errors:
|
|
1696
|
+
err_lines.append((xs[okf], ys_s[okf], f[okf]))
|
|
1697
|
+
if fit == "lm":
|
|
1698
|
+
for lv in se_levels:
|
|
1699
|
+
se_polys.append(_se_band(xs, ys_s, f, lv))
|
|
1700
|
+
|
|
1701
|
+
# ----- ellipses ------------------------------------------------
|
|
1702
|
+
if ellipse is True:
|
|
1703
|
+
ellipse = 0.95
|
|
1704
|
+
ellipses = []
|
|
1705
|
+
if ellipse:
|
|
1706
|
+
if not 0 < ellipse < 1:
|
|
1707
|
+
raise ValueError("ellipse is the confidence level of "
|
|
1708
|
+
"a data ellipse, between 0 and 1")
|
|
1709
|
+
for nm, xg, yg in groups:
|
|
1710
|
+
if len(xg) < 3:
|
|
1711
|
+
who = "" if nm is None else f" for group '{nm}'"
|
|
1712
|
+
raise ValueError(
|
|
1713
|
+
f"ellipse: need at least 3 points{who}, "
|
|
1714
|
+
f"found {len(xg)}")
|
|
1715
|
+
ellipses.append(_ellipse_region(xg, yg, ellipse))
|
|
1716
|
+
|
|
1717
|
+
if digits_d is None:
|
|
1718
|
+
digits_d = 2
|
|
1719
|
+
|
|
1720
|
+
# ----- contour: filled joint density (form="contour") ----------
|
|
1721
|
+
if form == "contour":
|
|
1722
|
+
fig = plt_contour(
|
|
1723
|
+
xv, yv, contour_n, contour_nbins, contour_points,
|
|
1724
|
+
pt_size,
|
|
1725
|
+
x_lab=x if xlab is None else xlab,
|
|
1726
|
+
y_lab=y if ylab is None else ylab,
|
|
1727
|
+
main=main, digits_d=digits_d,
|
|
1728
|
+
ell95=_ellipse_region(xv, yv, 0.95),
|
|
1729
|
+
ellipses=ellipses,
|
|
1730
|
+
ellipse_color=(get_option("ellipse_color", "gray20")
|
|
1731
|
+
if ellipse_color is None
|
|
1732
|
+
else ellipse_color),
|
|
1733
|
+
ellipse_lwd=(get_option("ellipse_lwd", 1)
|
|
1734
|
+
if ellipse_lwd is None else ellipse_lwd),
|
|
1735
|
+
fit_lines=fit_lines,
|
|
1736
|
+
fit_color=(get_option("fit_color", "#5C4032")
|
|
1737
|
+
if fit_color is None else fit_color),
|
|
1738
|
+
fit_lwd=(get_option("fit_lwd", 2)
|
|
1739
|
+
if fit_lwd is None else fit_lwd),
|
|
1740
|
+
legend=contour_legend,
|
|
1741
|
+
axis_fmt=axis_fmt, axis_x_pre=axis_x_pre,
|
|
1742
|
+
axis_y_pre=axis_y_pre)
|
|
1743
|
+
_apply_rotate(fig, rotate_x, rotate_y)
|
|
1744
|
+
if not resolve_quiet(quiet):
|
|
1745
|
+
print("\n".join(xy_stats(groups, x, y, fit_stats,
|
|
1746
|
+
digits_d)))
|
|
1747
|
+
if md_lines is not None:
|
|
1748
|
+
print("\n" + "\n".join(md_lines))
|
|
1749
|
+
return fig
|
|
1750
|
+
|
|
1751
|
+
# ----- forecast (date x, ts_source="classic") -------------------
|
|
1752
|
+
frcst = None
|
|
1753
|
+
if ts_ahead > 0:
|
|
1754
|
+
frcst = plt_forecast(
|
|
1755
|
+
xv, yv, x, y, ts_unit=ts_unit, ts_ahead=ts_ahead,
|
|
1756
|
+
ts_method=ts_method, ts_source=ts_source,
|
|
1757
|
+
ts_error=ts_error, ts_trend=ts_trend,
|
|
1758
|
+
ts_seasons=ts_seasons, ts_alpha=ts_alpha,
|
|
1759
|
+
ts_beta=ts_beta, ts_gamma=ts_gamma, ts_PI=ts_PI,
|
|
1760
|
+
digits_d=digits_d)
|
|
1761
|
+
|
|
1762
|
+
# ----- axes -----------------------------------------------------
|
|
1763
|
+
ys_all = np.concatenate(
|
|
1764
|
+
[yv] + [fl["y"] for fl in fit_lines]
|
|
1765
|
+
+ [b[1] for b in se_polys] + [e[1] for e in ellipses]
|
|
1766
|
+
+ [p[1] for p in area_polys]
|
|
1767
|
+
+ ([frcst["y_fit"], frcst["y_hat"],
|
|
1768
|
+
frcst["y_lwr"], frcst["y_upr"]] if frcst else []))
|
|
1769
|
+
if scale_y is not None: # explicit y scale
|
|
1770
|
+
axT2 = np.linspace(float(scale_y[0]), float(scale_y[1]),
|
|
1771
|
+
int(scale_y[2]))
|
|
1772
|
+
else:
|
|
1773
|
+
axT2 = pretty(float(np.nanmin(ys_all)),
|
|
1774
|
+
float(np.nanmax(ys_all)))
|
|
1775
|
+
axL2 = axis_format(axT2, digits_d, axis_fmt, axis_y_pre)
|
|
1776
|
+
if is_date:
|
|
1777
|
+
ax = {"axT1": None, "axL1": None, "axT2": axT2,
|
|
1778
|
+
"axL2": axL2}
|
|
1779
|
+
gridT1 = None
|
|
1780
|
+
else:
|
|
1781
|
+
xs_all = np.concatenate(
|
|
1782
|
+
[xv] + [fl["x"] for fl in fit_lines]
|
|
1783
|
+
+ [e[0] for e in ellipses])
|
|
1784
|
+
if scale_x is not None: # explicit x scale
|
|
1785
|
+
axT1 = np.linspace(float(scale_x[0]),
|
|
1786
|
+
float(scale_x[1]),
|
|
1787
|
+
int(scale_x[2]))
|
|
1788
|
+
else:
|
|
1789
|
+
axT1 = pretty(float(np.nanmin(xs_all)),
|
|
1790
|
+
float(np.nanmax(xs_all)))
|
|
1791
|
+
ax = {"axT1": axT1,
|
|
1792
|
+
"axL1": axis_format(axT1, digits_d, axis_fmt,
|
|
1793
|
+
axis_x_pre),
|
|
1794
|
+
"axT2": axT2, "axL2": axL2}
|
|
1795
|
+
gridT1 = axT1
|
|
1796
|
+
|
|
1797
|
+
# ----- render ---------------------------------------------------
|
|
1798
|
+
if form == "smooth":
|
|
1799
|
+
# plot window: data and overlay extent + 4% (R xaxs="r");
|
|
1800
|
+
# ticks outside the window drop, as on the scatter form
|
|
1801
|
+
x_lo = float(np.nanmin(xs_all))
|
|
1802
|
+
x_hi = float(np.nanmax(xs_all))
|
|
1803
|
+
y_lo = float(np.nanmin(ys_all))
|
|
1804
|
+
y_hi = float(np.nanmax(ys_all))
|
|
1805
|
+
pad_x = 0.04 * (x_hi - x_lo)
|
|
1806
|
+
pad_y = 0.04 * (y_hi - y_lo)
|
|
1807
|
+
fig = plt_smooth(
|
|
1808
|
+
xv, yv, smooth_points, smooth_size, smooth_power,
|
|
1809
|
+
smooth_bins,
|
|
1810
|
+
x_lab=x if xlab is None else xlab,
|
|
1811
|
+
y_lab=y if ylab is None else ylab,
|
|
1812
|
+
main=main, digits_d=digits_d,
|
|
1813
|
+
ax=ax, gridT1=gridT1, gridT2=ax["axT2"],
|
|
1814
|
+
x_lim=[x_lo - pad_x, x_hi + pad_x],
|
|
1815
|
+
y_lim=[y_lo - pad_y, y_hi + pad_y],
|
|
1816
|
+
fit_lines=fit_lines,
|
|
1817
|
+
fit_color=(get_option("fit_color", "#5C4032")
|
|
1818
|
+
if fit_color is None else fit_color),
|
|
1819
|
+
fit_lwd=(get_option("fit_lwd", 2)
|
|
1820
|
+
if fit_lwd is None else fit_lwd),
|
|
1821
|
+
se_polys=se_polys,
|
|
1822
|
+
se_fill=get_option("se_fill", "#1A1A1A19"),
|
|
1823
|
+
ellipses=ellipses,
|
|
1824
|
+
ellipse_fill=(get_option("ellipse_fill", "#92806F28")
|
|
1825
|
+
if ellipse_fill is None
|
|
1826
|
+
else ellipse_fill),
|
|
1827
|
+
ellipse_color=(get_option("ellipse_color", "gray20")
|
|
1828
|
+
if ellipse_color is None
|
|
1829
|
+
else ellipse_color),
|
|
1830
|
+
ellipse_lwd=(get_option("ellipse_lwd", 1)
|
|
1831
|
+
if ellipse_lwd is None
|
|
1832
|
+
else ellipse_lwd))
|
|
1833
|
+
_apply_rotate(fig, rotate_x, rotate_y)
|
|
1834
|
+
if add_means:
|
|
1835
|
+
_add_means(fig, xv, yv)
|
|
1836
|
+
if not resolve_quiet(quiet):
|
|
1837
|
+
print("\n".join(xy_stats(groups, x, y, fit_stats,
|
|
1838
|
+
digits_d)))
|
|
1839
|
+
return fig
|
|
1840
|
+
|
|
1841
|
+
fig = plt_plotly(
|
|
1842
|
+
disp_groups, by_name=by,
|
|
1843
|
+
fill=fills, border=borders, shape=pt_shape, pt_size=pt_size,
|
|
1844
|
+
x_lab=x if xlab is None else xlab,
|
|
1845
|
+
y_lab=y if ylab is None else ylab,
|
|
1846
|
+
ax=ax, gridT1=gridT1, gridT2=ax["axT2"],
|
|
1847
|
+
main=main, digits_d=digits_d,
|
|
1848
|
+
connect=is_date or show_runs or index_x, is_date=is_date,
|
|
1849
|
+
pt_opacity=1 - transparency,
|
|
1850
|
+
area_polys=area_polys,
|
|
1851
|
+
fit_lines=fit_lines, fit_color=fit_color, fit_lwd=fit_lwd,
|
|
1852
|
+
se_polys=se_polys,
|
|
1853
|
+
se_fill=get_option("se_fill", "#1A1A1A19"),
|
|
1854
|
+
ellipses=ellipses,
|
|
1855
|
+
ellipse_fill=(get_option("ellipse_fill", "#92806F28")
|
|
1856
|
+
if ellipse_fill is None else ellipse_fill),
|
|
1857
|
+
ellipse_color=(get_option("ellipse_color", "gray20")
|
|
1858
|
+
if ellipse_color is None else ellipse_color),
|
|
1859
|
+
ellipse_lwd=(get_option("ellipse_lwd", 1)
|
|
1860
|
+
if ellipse_lwd is None else ellipse_lwd),
|
|
1861
|
+
)
|
|
1862
|
+
|
|
1863
|
+
if (form == "scatter" and not is_date and not index_x
|
|
1864
|
+
and not show_runs):
|
|
1865
|
+
# square plot box, as R's default device (a time series, a
|
|
1866
|
+
# .Index run chart, or a runs chart keeps its wide aspect)
|
|
1867
|
+
fig.update_layout(**square_layout(main=bool(main)))
|
|
1868
|
+
|
|
1869
|
+
# plot_errors: residual segment from each point to its fitted
|
|
1870
|
+
# value, drawn over the points (R plt.main.R ~1403, rgb 130,40,35)
|
|
1871
|
+
if plot_errors and err_lines:
|
|
1872
|
+
ex, ey = [], []
|
|
1873
|
+
for xs_, yo_, ff_ in err_lines:
|
|
1874
|
+
for i in range(len(xs_)):
|
|
1875
|
+
ex += [xs_[i], xs_[i], None]
|
|
1876
|
+
ey += [yo_[i], ff_[i], None]
|
|
1877
|
+
fig.add_trace(go.Scatter(
|
|
1878
|
+
x=ex, y=ey, mode="lines",
|
|
1879
|
+
line=dict(color="#822823", width=1),
|
|
1880
|
+
hoverinfo="skip", showlegend=False))
|
|
1881
|
+
|
|
1882
|
+
# runs test: connect the points and report the run analysis
|
|
1883
|
+
# below; the run chart's center line is the median, so a run
|
|
1884
|
+
# defaults center_line to "median" (XY.R ~809)
|
|
1885
|
+
run_lines = None
|
|
1886
|
+
if show_runs:
|
|
1887
|
+
m_run, run_lines = _run_analysis(yv, digits_d, True)
|
|
1888
|
+
if center_line == "off":
|
|
1889
|
+
center_line = "median"
|
|
1890
|
+
|
|
1891
|
+
# center line: a gray dashed reference at the mean, median, or
|
|
1892
|
+
# zero, labeled on the right (R plt.main.R ~988)
|
|
1893
|
+
if center_line != "off":
|
|
1894
|
+
if center_line == "mean":
|
|
1895
|
+
m_cl, lbl = float(np.mean(yv)), " mean"
|
|
1896
|
+
elif center_line == "median":
|
|
1897
|
+
m_cl, lbl = float(np.median(yv)), " median"
|
|
1898
|
+
else: # "zero": line but no label
|
|
1899
|
+
m_cl, lbl = 0.0, ""
|
|
1900
|
+
gray = to_hex("gray50")
|
|
1901
|
+
fig.add_hline(y=m_cl, line=dict(color=gray, dash="dash",
|
|
1902
|
+
width=1))
|
|
1903
|
+
if lbl:
|
|
1904
|
+
fig.add_annotation(x=1, xref="paper", y=m_cl, yref="y",
|
|
1905
|
+
text=lbl, showarrow=False,
|
|
1906
|
+
xanchor="left",
|
|
1907
|
+
font=dict(size=10, color=gray))
|
|
1908
|
+
|
|
1909
|
+
if area_yrange is not None:
|
|
1910
|
+
fig.update_layout(yaxis_range=area_yrange)
|
|
1911
|
+
|
|
1912
|
+
if add_means: # enhance: mean crosshair
|
|
1913
|
+
_add_means(fig, xv, yv)
|
|
1914
|
+
|
|
1915
|
+
if scale_x is not None: # explicit axis ranges
|
|
1916
|
+
fig.update_xaxes(range=[float(scale_x[0]),
|
|
1917
|
+
float(scale_x[1])])
|
|
1918
|
+
if scale_y is not None:
|
|
1919
|
+
fig.update_yaxes(range=[float(scale_y[0]),
|
|
1920
|
+
float(scale_y[1])])
|
|
1921
|
+
if is_date and ts_n_x_tics is not None: # date-axis tick count
|
|
1922
|
+
fig.update_xaxes(nticks=int(ts_n_x_tics))
|
|
1923
|
+
_apply_rotate(fig, rotate_x, rotate_y)
|
|
1924
|
+
|
|
1925
|
+
if add is not None: # add= annotations
|
|
1926
|
+
add_l = list(add) if isinstance(add, (list, tuple)) \
|
|
1927
|
+
else [add]
|
|
1928
|
+
if add_l and add_l[0] == "means": # XY.R ~1405-1410
|
|
1929
|
+
_add_means(fig, xv, yv)
|
|
1930
|
+
add_l = add_l[1:]
|
|
1931
|
+
if add_l:
|
|
1932
|
+
def _res(v, key, val): # "mean_x"/"mean_y"
|
|
1933
|
+
if v is None:
|
|
1934
|
+
return None
|
|
1935
|
+
vv = (list(v) if isinstance(v, (list, tuple))
|
|
1936
|
+
else [v])
|
|
1937
|
+
return [val if u == key else u for u in vv]
|
|
1938
|
+
mx_v = None if is_date else float(xv.mean())
|
|
1939
|
+
my_v = float(yv.mean())
|
|
1940
|
+
plt_add(fig, add_l,
|
|
1941
|
+
x1=_res(x1, "mean_x", mx_v),
|
|
1942
|
+
x2=_res(x2, "mean_x", mx_v),
|
|
1943
|
+
y1=_res(y1, "mean_y", my_v),
|
|
1944
|
+
y2=_res(y2, "mean_y", my_v))
|
|
1945
|
+
|
|
1946
|
+
if out_idx is not None and len(out_idx) > 0:
|
|
1947
|
+
_outlier_traces(fig, xv, yv, out_idx, md_ids, fills[0],
|
|
1948
|
+
1 - transparency, pt_size, out_shape,
|
|
1949
|
+
out_size, ID_color, ID_size)
|
|
1950
|
+
if fit != "off": # second fit, outliers
|
|
1951
|
+
m = np.ones(len(xv), bool) # removed, drawn dashed
|
|
1952
|
+
m[out_idx] = False
|
|
1953
|
+
if fit == "loess":
|
|
1954
|
+
xs2, _, f2, _ = _loess(xv[m], yv[m], span)
|
|
1955
|
+
else:
|
|
1956
|
+
xs2, _, f2 = _plt_fit(xv[m], yv[m], fit,
|
|
1957
|
+
fit_power)
|
|
1958
|
+
ok2 = np.isfinite(f2)
|
|
1959
|
+
xs2, f2 = xs2[ok2], f2[ok2]
|
|
1960
|
+
fig.add_trace(go.Scatter(
|
|
1961
|
+
x=xs2, y=f2, mode="lines",
|
|
1962
|
+
name="Fit (no outliers)",
|
|
1963
|
+
line=dict(color=to_hex(
|
|
1964
|
+
get_option("fit_color", "#5C4032")
|
|
1965
|
+
if fit_color is None else fit_color),
|
|
1966
|
+
width=get_option("fit_lwd", 2)
|
|
1967
|
+
if fit_lwd is None else fit_lwd,
|
|
1968
|
+
dash="dash"),
|
|
1969
|
+
showlegend=True))
|
|
1970
|
+
|
|
1971
|
+
if frcst is not None:
|
|
1972
|
+
_forecast_traces(fig, frcst, ts_PI)
|
|
1973
|
+
if not resolve_quiet(quiet):
|
|
1974
|
+
for ln in frcst["report"]:
|
|
1975
|
+
print(ln)
|
|
1976
|
+
if ts_fitted: # observed vs model-fit table
|
|
1977
|
+
print("\n".join(_ts_fitted_lines(
|
|
1978
|
+
frcst, yv, x, y, ts_unit, digits_d)))
|
|
1979
|
+
print("\nForecast\n--------")
|
|
1980
|
+
print(frcst["forecast"].to_string(
|
|
1981
|
+
float_format=lambda v: f"{v:.{digits_d + 2}f}"))
|
|
1982
|
+
elif not is_date and not resolve_quiet(quiet):
|
|
1983
|
+
print("\n".join(xy_stats(groups, x, y, fit_stats,
|
|
1984
|
+
digits_d)))
|
|
1985
|
+
if fit_new is not None and fit in _FIT_NEW_OK:
|
|
1986
|
+
print("\n".join(_fit_new_table(
|
|
1987
|
+
groups, fit, fit_power, fit_new, x, y, digits_d)))
|
|
1988
|
+
if run_lines is not None:
|
|
1989
|
+
print("\n".join(run_lines))
|
|
1990
|
+
if md_lines is not None:
|
|
1991
|
+
print("\n" + "\n".join(md_lines))
|
|
1992
|
+
if jitter_x > 0 or jitter_y > 0:
|
|
1993
|
+
print("\nSome Parameter values (can be manually set)")
|
|
1994
|
+
print("-" * 55)
|
|
1995
|
+
print(f"size: {float(pt_size):.2f} "
|
|
1996
|
+
" size of plotted points")
|
|
1997
|
+
if jitter_y > 0:
|
|
1998
|
+
print(f"jitter_y: {jitter_y:.2f} "
|
|
1999
|
+
" random vertical movement of points")
|
|
2000
|
+
if jitter_x > 0:
|
|
2001
|
+
print(f"jitter_x: {jitter_x:.2f} "
|
|
2002
|
+
" random horizontal movement of points")
|
|
2003
|
+
return fig
|
|
2004
|
+
|
|
2005
|
+
|
|
2006
|
+
# font_size= scales all text of the returned figure
|
|
2007
|
+
XY = font_scaled(XY)
|