lessPython 0.2.0__py3-none-any.whl → 0.3.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 +5 -5
- lessPy/Chart.py +386 -88
- lessPy/Correlation.py +2 -2
- lessPy/Logit.py +2 -2
- lessPy/Regression.py +6 -4
- lessPy/X.py +279 -42
- lessPy/XY.py +814 -158
- lessPy/__init__.py +12 -2
- lessPy/_themes.py +5007 -0
- lessPy/bc_items_plotly.py +31 -18
- lessPy/bc_plotly.py +23 -7
- lessPy/bubble_plotly.py +17 -2
- lessPy/corCFA.py +3 -3
- lessPy/corProp.py +2 -2
- lessPy/corReflect.py +2 -2
- lessPy/corReorder.py +2 -2
- lessPy/corScree.py +3 -3
- lessPy/data/StockPrice.csv +1401 -1386
- lessPy/details.py +17 -4
- lessPy/dn_plotly.py +65 -45
- lessPy/freq_poly_plotly.py +12 -5
- lessPy/getColors.py +247 -20
- lessPy/hier_plotly.py +36 -9
- lessPy/hs_plotly.py +16 -4
- lessPy/plotly_utils.py +78 -9
- lessPy/plt_mat_plotly.py +74 -2
- lessPy/plt_time.py +10 -3
- lessPy/prob_norm.py +3 -3
- lessPy/prob_tcut.py +3 -3
- lessPy/prob_znorm.py +3 -1
- lessPy/rcolors.py +461 -0
- lessPy/simCImean.py +3 -3
- lessPy/simCLT.py +3 -3
- lessPy/simFlips.py +3 -3
- lessPy/simMeans.py +3 -3
- lessPy/stats_out.py +257 -11
- lessPy/style.py +160 -0
- lessPy/sunflower_plotly.py +140 -0
- lessPy/ttest.py +5 -5
- lessPy/utils.py +43 -6
- lessPy/vbs_plotly.py +16 -5
- lessPy/x_console.py +763 -0
- {lesspython-0.2.0.dist-info → lesspython-0.3.0.dist-info}/METADATA +6 -2
- lesspython-0.3.0.dist-info/RECORD +89 -0
- lesspython-0.2.0.dist-info/RECORD +0 -84
- {lesspython-0.2.0.dist-info → lesspython-0.3.0.dist-info}/WHEEL +0 -0
- {lesspython-0.2.0.dist-info → lesspython-0.3.0.dist-info}/licenses/LICENSE +0 -0
- {lesspython-0.2.0.dist-info → lesspython-0.3.0.dist-info}/top_level.txt +0 -0
lessPy/XY.py
CHANGED
|
@@ -63,7 +63,10 @@ from .plt_forecast import plt_forecast
|
|
|
63
63
|
from .plt_plotly import plt_plotly
|
|
64
64
|
from .plt_smooth import plt_smooth
|
|
65
65
|
from .plt_time import plt_time
|
|
66
|
+
from .sunflower_plotly import sunflower_plotly
|
|
66
67
|
from .plt_mat_plotly import scatter_matrix
|
|
68
|
+
from . import x_console as xc
|
|
69
|
+
from .stats_out import _stat_by_levels
|
|
67
70
|
from .stats_out import (
|
|
68
71
|
facet_summary, md_outliers, resolve_quiet, xy_stats)
|
|
69
72
|
from .plotly_utils import (
|
|
@@ -71,12 +74,13 @@ from .plotly_utils import (
|
|
|
71
74
|
facet_fig, facet_panels, finish_facet,
|
|
72
75
|
get_tick_fmt, legend_style, make_trans, plot_border,
|
|
73
76
|
plotly_style, square_layout, sym_at, to_hex, x_grid,
|
|
74
|
-
font_scaled)
|
|
77
|
+
font_scaled, by_colors)
|
|
75
78
|
from .utils import (
|
|
76
|
-
|
|
79
|
+
STAT_FUN, STAT_LBL, category_order, get_column, get_option,
|
|
80
|
+
pretty, resolve_facet,
|
|
77
81
|
)
|
|
78
82
|
|
|
79
|
-
_FORMS = ("scatter", "smooth", "contour")
|
|
83
|
+
_FORMS = ("scatter", "smooth", "contour", "sunflower")
|
|
80
84
|
_FITS = ("off", "loess", "lm", "ls", "null", "exp", "quad",
|
|
81
85
|
"power", "log")
|
|
82
86
|
|
|
@@ -191,41 +195,219 @@ def _fit_new_table(groups, fit, fit_power, x_new, x_name, y_name,
|
|
|
191
195
|
return lines
|
|
192
196
|
|
|
193
197
|
|
|
194
|
-
def
|
|
195
|
-
"""
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
R analog:
|
|
198
|
+
def _loess_rows(xs, q_pt, span, degree, rw):
|
|
199
|
+
"""The local fit at q_pt as linear operators on y: rows giving
|
|
200
|
+
the fitted value and the slope there. Tri-cube weights on the
|
|
201
|
+
floor(n*span) nearest points (times the robustness weights),
|
|
202
|
+
polynomial of the given degree centered at q_pt.
|
|
203
|
+
R analog: ehg127() of the loess Fortran"""
|
|
204
|
+
n = len(xs)
|
|
205
|
+
q = min(n, max(int(span * n + 1e-5), degree + 1))
|
|
206
|
+
d = np.abs(xs - q_pt)
|
|
207
|
+
h = np.partition(d, q - 1)[q - 1]
|
|
208
|
+
if span > 1: # window widens past data,
|
|
209
|
+
h *= span ** 0.5 # verified against R loess
|
|
210
|
+
if h > 0:
|
|
211
|
+
w = np.clip(1 - (d / h) ** 3, 0, 1) ** 3
|
|
212
|
+
else: # window is exact x ties
|
|
213
|
+
w = (d == 0).astype(float)
|
|
214
|
+
w = w * rw
|
|
215
|
+
nz = np.flatnonzero(w)
|
|
216
|
+
xc = xs[nz] - q_pt
|
|
217
|
+
X = np.column_stack([xc ** k for k in range(degree + 1)])
|
|
218
|
+
XtW = X.T * w[nz]
|
|
219
|
+
B = np.linalg.pinv(XtW @ X) @ XtW
|
|
220
|
+
val = np.zeros(n)
|
|
221
|
+
slope = np.zeros(n)
|
|
222
|
+
val[nz] = B[0]
|
|
223
|
+
slope[nz] = B[1]
|
|
224
|
+
return val, slope
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _loess_kd(xs, span, cell=0.2):
|
|
228
|
+
"""Leaf cells of R's kd tree on one sorted x: the bounding box
|
|
229
|
+
widened by 0.5% each side, a cell split at its median x (moved
|
|
230
|
+
off ties) while it holds more than floor(n*span*cell) points.
|
|
231
|
+
Returns (lower, upper, split) per leaf, the split for the walk
|
|
232
|
+
down the tree. R analog: ehg126(), ehg124()"""
|
|
233
|
+
n = len(xs)
|
|
234
|
+
lo, hi = float(xs[0]), float(xs[-1])
|
|
235
|
+
mu = 0.005 * max(hi - lo, 1e-10 * max(abs(lo), abs(hi)) + 1e-30)
|
|
236
|
+
fc = int(np.floor(n * span * cell))
|
|
237
|
+
nodes = [] # (l, u, vlo, vhi), 1-based
|
|
238
|
+
|
|
239
|
+
def build(l, u, vlo, vhi):
|
|
240
|
+
leaf = (u - l) + 1 <= fc
|
|
241
|
+
if not leaf:
|
|
242
|
+
m = (l + u) // 2
|
|
243
|
+
off = 0
|
|
244
|
+
while l <= m + off < u:
|
|
245
|
+
if xs[m + off - 1] == xs[m + off]:
|
|
246
|
+
off = -off
|
|
247
|
+
if off >= 0:
|
|
248
|
+
off += 1
|
|
249
|
+
else:
|
|
250
|
+
m += off
|
|
251
|
+
break
|
|
252
|
+
v = float(xs[m - 1])
|
|
253
|
+
leaf = v == vlo or v == vhi
|
|
254
|
+
if leaf:
|
|
255
|
+
nodes.append((vlo, vhi))
|
|
256
|
+
return
|
|
257
|
+
build(l, m, vlo, v)
|
|
258
|
+
build(m + 1, u, v, vhi)
|
|
259
|
+
|
|
260
|
+
build(1, n, lo - mu, hi + mu)
|
|
261
|
+
return nodes
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _loess(xv, yv, span, degree=2, family="gaussian"):
|
|
265
|
+
"""R's default loess at the sorted x values: local fits of the
|
|
266
|
+
given degree at the vertices of a kd tree, each giving a value
|
|
267
|
+
and a slope, joined by cubic Hermite interpolation within each
|
|
268
|
+
cell (surface="interpolate"). family="symmetric" reweights by
|
|
269
|
+
the bisquare of the residuals over 4 iterations. Also the SE of
|
|
270
|
+
fit from the rows of the resulting smoother, as predict(l.ln,
|
|
271
|
+
se=TRUE). span may be given as (span, degree, family).
|
|
272
|
+
R analog: .plt.fit() loess(y.lv ~ x.lv, span, degree, family)"""
|
|
273
|
+
if isinstance(span, tuple):
|
|
274
|
+
span, degree, family = span
|
|
200
275
|
od = np.argsort(xv, kind="stable")
|
|
201
|
-
xs, ys = xv[od], yv[od]
|
|
276
|
+
xs, ys = np.asarray(xv, float)[od], np.asarray(yv, float)[od]
|
|
202
277
|
n = len(xs)
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
for
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
f
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
278
|
+
cells = _loess_kd(xs, span)
|
|
279
|
+
verts = sorted({v for c in cells for v in c})
|
|
280
|
+
rw = np.ones(n)
|
|
281
|
+
n_iter = 4 if family == "symmetric" else 1
|
|
282
|
+
for it in range(n_iter):
|
|
283
|
+
rows = {v: _loess_rows(xs, v, span, degree, rw) for v in verts}
|
|
284
|
+
L = np.empty((n, n))
|
|
285
|
+
for i, z in enumerate(xs):
|
|
286
|
+
for v0, v1 in cells: # x on a split goes left
|
|
287
|
+
if z <= v1:
|
|
288
|
+
break
|
|
289
|
+
hh = v1 - v0
|
|
290
|
+
u = (z - v0) / hh
|
|
291
|
+
g0, s0 = rows[v0]
|
|
292
|
+
g1, s1 = rows[v1]
|
|
293
|
+
L[i] = ((1 - u) ** 2 * (1 + 2 * u) * g0
|
|
294
|
+
+ u * u * (3 - 2 * u) * g1
|
|
295
|
+
+ (u * (1 - u) ** 2 * s0 - u * u * (1 - u) * s1) * hh)
|
|
296
|
+
f = L @ ys
|
|
297
|
+
res = ys - f
|
|
298
|
+
if it < n_iter - 1: # R lowesw(): bisquare weights
|
|
299
|
+
cmad = 6 * np.median(np.abs(res))
|
|
300
|
+
if cmad < np.finfo(float).tiny:
|
|
301
|
+
rw = np.ones(n)
|
|
302
|
+
else:
|
|
303
|
+
r = np.abs(res)
|
|
304
|
+
rw = np.where(r > 0.999 * cmad, 0.0,
|
|
305
|
+
np.where(r > 0.001 * cmad,
|
|
306
|
+
(1 - (r / cmad) ** 2) ** 2, 1.0))
|
|
307
|
+
l2 = (L ** 2).sum(axis=1)
|
|
308
|
+
dof = n - 2 * np.trace(L) + l2.sum() # tr[(I-L)'(I-L)]
|
|
226
309
|
return xs, ys, f, np.sqrt(res @ res / dof * l2)
|
|
227
310
|
|
|
228
311
|
|
|
312
|
+
_QQ_LAB = {".normal": "Normal Quantiles",
|
|
313
|
+
".lognormal": "Lognormal Quantiles",
|
|
314
|
+
".exponential": "Exponential Quantiles",
|
|
315
|
+
".uniform": "Uniform Quantiles"}
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _qq_quantiles(y, key):
|
|
319
|
+
"""Theoretical quantiles, one per value of y in the row order of
|
|
320
|
+
y (NaN where y is missing): the i-th smallest y pairs with the
|
|
321
|
+
i-th smallest quantile of the distribution fit by y's moments.
|
|
322
|
+
R analog: .qq.quantiles() (qq.R)"""
|
|
323
|
+
y = np.asarray(y, dtype=float)
|
|
324
|
+
ok = ~np.isnan(y)
|
|
325
|
+
n = int(ok.sum())
|
|
326
|
+
if n < 3:
|
|
327
|
+
raise ValueError(
|
|
328
|
+
"A q-q chart requires at least 3 non-missing values of y, "
|
|
329
|
+
f"but only {n} found.")
|
|
330
|
+
yv = y[ok]
|
|
331
|
+
m, sd = yv.mean(), yv.std(ddof=1)
|
|
332
|
+
if sd == 0:
|
|
333
|
+
raise ValueError(
|
|
334
|
+
"All values of y are the same, so there is no "
|
|
335
|
+
"distribution\nto compare against a theoretical "
|
|
336
|
+
"distribution.")
|
|
337
|
+
if key == ".lognormal" and (yv <= 0).any():
|
|
338
|
+
raise ValueError(
|
|
339
|
+
"A lognormal distribution is defined only for positive "
|
|
340
|
+
f"values,\nbut y has {(yv <= 0).sum()} value(s) of zero or "
|
|
341
|
+
"less.\n\nFor data that include zero, consider .exponential")
|
|
342
|
+
if key == ".exponential" and (yv < 0).any():
|
|
343
|
+
raise ValueError(
|
|
344
|
+
"An exponential distribution is defined only for "
|
|
345
|
+
f"non-negative\nvalues, but y has {(yv < 0).sum()} "
|
|
346
|
+
"negative value(s).")
|
|
347
|
+
a = 3 / 8 if n <= 10 else 0.5 # R ppoints()
|
|
348
|
+
p = (np.arange(1, n + 1) - a) / (n + 1 - 2 * a)
|
|
349
|
+
if key == ".normal":
|
|
350
|
+
q = sps.norm.ppf(p, m, sd)
|
|
351
|
+
elif key == ".lognormal":
|
|
352
|
+
lg = np.log(yv)
|
|
353
|
+
q = np.exp(sps.norm.ppf(p, lg.mean(), lg.std(ddof=1)))
|
|
354
|
+
elif key == ".exponential":
|
|
355
|
+
q = -m * np.log1p(-p)
|
|
356
|
+
else:
|
|
357
|
+
q = m - np.sqrt(3) * sd + p * 2 * np.sqrt(3) * sd
|
|
358
|
+
out = np.full(len(y), np.nan)
|
|
359
|
+
rk = np.empty(n, dtype=int) # rank, ties.method="first"
|
|
360
|
+
rk[np.argsort(yv, kind="stable")] = np.arange(n)
|
|
361
|
+
out[ok] = q[rk]
|
|
362
|
+
return out
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _qq_reference(fig, fit_color, fit_lwd):
|
|
366
|
+
"""The 45-degree reference of a q-q chart in every panel, from
|
|
367
|
+
the range of that panel's plotted values, drawn beneath the
|
|
368
|
+
points. R analog: plt.plotly.R qq reference"""
|
|
369
|
+
panels = {}
|
|
370
|
+
for tr in fig.data:
|
|
371
|
+
if tr.type != "scatter" or tr.x is None or tr.y is None:
|
|
372
|
+
continue
|
|
373
|
+
key = (tr.xaxis or "x", tr.yaxis or "y")
|
|
374
|
+
v = np.r_[np.asarray(tr.x, float), np.asarray(tr.y, float)]
|
|
375
|
+
v = v[np.isfinite(v)]
|
|
376
|
+
if len(v):
|
|
377
|
+
lo, hi = panels.get(key, (np.inf, -np.inf))
|
|
378
|
+
panels[key] = (min(lo, v.min()), max(hi, v.max()))
|
|
379
|
+
refs = [go.Scatter(x=[lo, hi], y=[lo, hi], mode="lines",
|
|
380
|
+
xaxis=xa, yaxis=ya,
|
|
381
|
+
line=dict(color=to_hex(get_option(
|
|
382
|
+
"fit_color", "#5C4032")
|
|
383
|
+
if fit_color is None else fit_color),
|
|
384
|
+
width=1.5 if fit_lwd is None else fit_lwd),
|
|
385
|
+
showlegend=False, hoverinfo="skip")
|
|
386
|
+
for (xa, ya), (lo, hi) in panels.items()]
|
|
387
|
+
k = len(refs)
|
|
388
|
+
fig.add_traces(refs)
|
|
389
|
+
fig.data = fig.data[-k:] + fig.data[:-k] if k else fig.data
|
|
390
|
+
return fig
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _ts_ylab(y_name, unit, aggregated, ts_agg):
|
|
394
|
+
"""Default value-axis label of a time series: the aggregation
|
|
395
|
+
and the time unit, as "Total Sales by Year"; a vector of y
|
|
396
|
+
(y_name None) is named by its legend, so "Total by Year", and
|
|
397
|
+
with no aggregation to report either, no label. R analog: the
|
|
398
|
+
y.lab of .plt.main() for a date x"""
|
|
399
|
+
if unit in (None, "unknown"):
|
|
400
|
+
return y_name or ""
|
|
401
|
+
tu = "days" if unit == "days7" else unit
|
|
402
|
+
tu = tu[:-1] if tu.endswith("s") else tu
|
|
403
|
+
agg = (("Total" if ts_agg == "sum" else "Mean")
|
|
404
|
+
if aggregated else "")
|
|
405
|
+
if y_name is None and not agg:
|
|
406
|
+
return ""
|
|
407
|
+
return " ".join(p for p in (agg, y_name, "by", tu.capitalize())
|
|
408
|
+
if p)
|
|
409
|
+
|
|
410
|
+
|
|
229
411
|
def _se_band(xs, ys, f, level):
|
|
230
412
|
"""SE band polygon about a least-squares line: fit +/- t * SE
|
|
231
413
|
of the conditional mean. R analog: plt.main.R se bands, from
|
|
@@ -683,8 +865,7 @@ def _xy_series(x, y, data, x_multi, by, facet, form, n_row,
|
|
|
683
865
|
elif fill is not None:
|
|
684
866
|
fills = [fill] * k
|
|
685
867
|
else:
|
|
686
|
-
fills =
|
|
687
|
-
for i in range(k)]
|
|
868
|
+
fills = by_colors(k) # theme's palette, as R
|
|
688
869
|
if transparency is None:
|
|
689
870
|
transparency = get_option("trans_pt_fill", 0.10)
|
|
690
871
|
if ellipse is True:
|
|
@@ -736,13 +917,19 @@ def _ts_facet(xv, yv, facet_arr, facet_order, fill0, border0,
|
|
|
736
917
|
pt_size, pt_opacity, x_lab, y_lab, main, digits_d,
|
|
737
918
|
area_fill=None, area_split=0,
|
|
738
919
|
facet_name=None, facet2_arr=None,
|
|
739
|
-
facet2_order=None, facet2_name=None, n_col=1
|
|
920
|
+
facet2_order=None, facet2_name=None, n_col=1,
|
|
921
|
+
by_arr=None, by_order=None, by_name=None,
|
|
922
|
+
line_width=1.5, frcsts=None, ts_PI=0.95):
|
|
740
923
|
"""One time-series panel per facet level on shared axes,
|
|
741
924
|
following the faceted-scatter conventions (_xy_facet):
|
|
742
925
|
first level on the top panel, strip labels. The x axis
|
|
743
926
|
uses plotly's native date ticks, as the single-panel time
|
|
744
927
|
series does. R analog: the lattice cont_cont path for a
|
|
745
|
-
date x. facet2: the two-facet grid, rows = facet2 levels.
|
|
928
|
+
date x. facet2: the two-facet grid, rows = facet2 levels.
|
|
929
|
+
by: several series per panel in the legend's colors, unfilled
|
|
930
|
+
unless area_fill is asked for. frcsts: a forecast per panel
|
|
931
|
+
(panel index -> plt_forecast result), every panel on one scale
|
|
932
|
+
that holds the prediction intervals."""
|
|
746
933
|
labels, pos, sel, n_row_g, n_col = facet_panels(
|
|
747
934
|
facet_arr, facet_order, facet2_arr, facet2_order,
|
|
748
935
|
facet_name, facet2_name, n_col)
|
|
@@ -752,7 +939,12 @@ def _ts_facet(xv, yv, facet_arr, facet_order, fill0, border0,
|
|
|
752
939
|
px = 5
|
|
753
940
|
mode_pts = "lines+markers" if px > 0 else "lines"
|
|
754
941
|
|
|
755
|
-
|
|
942
|
+
y_all = [np.asarray(yv, float)]
|
|
943
|
+
for fr in (frcsts or {}).values():
|
|
944
|
+
y_all += [np.asarray(fr["y_lwr"], float),
|
|
945
|
+
np.asarray(fr["y_upr"], float)]
|
|
946
|
+
y_all = np.concatenate(y_all)
|
|
947
|
+
axT2 = pretty(float(np.nanmin(y_all)), float(np.nanmax(y_all)))
|
|
756
948
|
fmt2 = get_tick_fmt(axT2, digits_d)
|
|
757
949
|
ax = {"axT1": None, "axL1": None,
|
|
758
950
|
"axT2": axT2,
|
|
@@ -768,34 +960,58 @@ def _ts_facet(xv, yv, facet_arr, facet_order, fill0, border0,
|
|
|
768
960
|
rng_pad = 0.04 * (axT2[-1] - axT2[0]) # panel-clipped
|
|
769
961
|
base = min(max(float(area_split), axT2[0] - rng_pad),
|
|
770
962
|
axT2[-1] + rng_pad)
|
|
963
|
+
if by_arr is None:
|
|
964
|
+
series = [(None, np.ones(len(xv), dtype=bool), fill0,
|
|
965
|
+
border0)]
|
|
966
|
+
else:
|
|
967
|
+
by_s = np.asarray(by_arr).astype(str)
|
|
968
|
+
cols = by_colors(len(by_order))
|
|
969
|
+
series = [(str(lv), by_s == str(lv), cols[g], cols[g])
|
|
970
|
+
for g, lv in enumerate(by_order)]
|
|
771
971
|
for i in range(n_f):
|
|
772
|
-
m = sel[i]
|
|
773
972
|
row, col = pos[i]
|
|
774
|
-
|
|
973
|
+
for nm, bm, f_c, b_c in series:
|
|
974
|
+
m = sel[i] & bm
|
|
975
|
+
if not m.any():
|
|
976
|
+
continue
|
|
977
|
+
od = np.argsort(xv[m], kind="stable")
|
|
978
|
+
xm, ym = xv[m][od], yv[m][od]
|
|
979
|
+
if area_fill is not None:
|
|
980
|
+
fig.add_trace(go.Scatter(
|
|
981
|
+
x=np.concatenate([xm, xm[-1:], xm[:1]]),
|
|
982
|
+
y=np.concatenate([ym, [base, base]]),
|
|
983
|
+
mode="none", fill="toself",
|
|
984
|
+
fillcolor=area_fill, hoverinfo="skip",
|
|
985
|
+
showlegend=False,
|
|
986
|
+
), row=row, col=col)
|
|
987
|
+
hv = hover if nm is None else (
|
|
988
|
+
f"{by_name}: {nm}<br>" + hover)
|
|
775
989
|
fig.add_trace(go.Scatter(
|
|
776
|
-
x=
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
hovertemplate=hover, showlegend=False,
|
|
794
|
-
), row=row, col=col) # first level top-left
|
|
990
|
+
x=xm, y=ym, mode=mode_pts if line_width > 0
|
|
991
|
+
else "markers",
|
|
992
|
+
marker=(dict(symbol="circle", size=px,
|
|
993
|
+
sizemode="diameter",
|
|
994
|
+
color=make_trans(f_c, pt_opacity),
|
|
995
|
+
opacity=1,
|
|
996
|
+
line=dict(color=to_hex(b_c), width=1))
|
|
997
|
+
if "markers" in mode_pts or line_width <= 0
|
|
998
|
+
else None),
|
|
999
|
+
line=dict(color=to_hex(b_c), width=line_width),
|
|
1000
|
+
name=nm, legendgroup=nm,
|
|
1001
|
+
hovertemplate=hv,
|
|
1002
|
+
showlegend=nm is not None and i == 0,
|
|
1003
|
+
), row=row, col=col) # first level top-left
|
|
1004
|
+
if frcsts and i in frcsts:
|
|
1005
|
+
_forecast_traces(fig, frcsts[i], ts_PI, row=row, col=col,
|
|
1006
|
+
legend=i == min(frcsts))
|
|
795
1007
|
|
|
796
1008
|
finish_facet(fig, labels, ax, x_lab, y_lab,
|
|
797
1009
|
gridT1=None, style_opts=style_opts,
|
|
798
1010
|
n_col=n_col, pos=pos)
|
|
1011
|
+
if by_arr is not None or frcsts:
|
|
1012
|
+
fig.update_layout(showlegend=True,
|
|
1013
|
+
legend=legend_style(by_name, style_opts)
|
|
1014
|
+
if by_arr is not None else None)
|
|
799
1015
|
# ts panels: y spans the data; native date grid on x
|
|
800
1016
|
pad = 0.04 * (axT2[-1] - axT2[0])
|
|
801
1017
|
fig.update_yaxes(range=[axT2[0] - pad, axT2[-1] + pad])
|
|
@@ -880,11 +1096,14 @@ def _run_analysis(yv, digits_d, show_detail):
|
|
|
880
1096
|
lines.append(f"\nTotal number of runs: {n_runs}")
|
|
881
1097
|
lines.append("Total number of values that do not equal the "
|
|
882
1098
|
f"median: {n - len(eq)}")
|
|
883
|
-
if eq:
|
|
1099
|
+
if eq: # listed only with show_runs
|
|
884
1100
|
if show_detail:
|
|
885
1101
|
lines.append("\nValues ignored that equal the median")
|
|
886
1102
|
lines += [f" #{j} {y[j - 1]:.{dd}f}" for j in eq]
|
|
887
|
-
|
|
1103
|
+
lines.append(f"Total number of values ignored: {len(eq)}")
|
|
1104
|
+
else:
|
|
1105
|
+
lines.append("Total number of values ignored that equal the "
|
|
1106
|
+
"median: 0")
|
|
888
1107
|
return m, lines
|
|
889
1108
|
|
|
890
1109
|
|
|
@@ -914,7 +1133,8 @@ def _outlier_traces(fig, xv, yv, out_idx, labels, fill0,
|
|
|
914
1133
|
family="Arial"))
|
|
915
1134
|
|
|
916
1135
|
|
|
917
|
-
def _forecast_traces(fig, frcst, ts_PI
|
|
1136
|
+
def _forecast_traces(fig, frcst, ts_PI, row=None, col=None,
|
|
1137
|
+
legend=True):
|
|
918
1138
|
"""Overlay the forecast on the time series display: model-fit
|
|
919
1139
|
line, PI band with dotted boundaries, forecast line + points.
|
|
920
1140
|
R analog: plt.plotly.R forecast section (~lines 343-451),
|
|
@@ -925,6 +1145,16 @@ def _forecast_traces(fig, frcst, ts_PI):
|
|
|
925
1145
|
xf = np.asarray(frcst["x_fit"]) # numpy datetimes: plain
|
|
926
1146
|
xh = np.asarray(frcst["x_hat"]) # Timestamps break kaleido
|
|
927
1147
|
yf, yh = frcst["y_fit"], frcst["y_hat"]
|
|
1148
|
+
at = {} if row is None else dict(row=row, col=col)
|
|
1149
|
+
_add = fig.add_trace
|
|
1150
|
+
|
|
1151
|
+
def add(tr, **kw): # one legend entry per role
|
|
1152
|
+
if not legend:
|
|
1153
|
+
tr.showlegend = False
|
|
1154
|
+
tr.legendgroup = tr.name
|
|
1155
|
+
_add(tr, **at)
|
|
1156
|
+
|
|
1157
|
+
fig = _Adder(fig, add)
|
|
928
1158
|
fig.add_trace(go.Scatter(
|
|
929
1159
|
x=xf, y=yf, mode="lines",
|
|
930
1160
|
line={"color": fit_rgba, "width": 1.5},
|
|
@@ -953,6 +1183,12 @@ def _forecast_traces(fig, frcst, ts_PI):
|
|
|
953
1183
|
name="Forecast", showlegend=True))
|
|
954
1184
|
|
|
955
1185
|
|
|
1186
|
+
class _Adder:
|
|
1187
|
+
"""fig.add_trace routed through a placement function."""
|
|
1188
|
+
def __init__(self, fig, add):
|
|
1189
|
+
self._fig, self.add_trace = fig, add
|
|
1190
|
+
|
|
1191
|
+
|
|
956
1192
|
def _ts_fitted_lines(frcst, yv, x_name, y_name, ts_unit, digits_d):
|
|
957
1193
|
"""Table of the observed y beside the model-fitted y over the
|
|
958
1194
|
historical dates, printed when ts_fitted=True. R analog: the
|
|
@@ -995,7 +1231,8 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
995
1231
|
ID=None, ID_color="gray50", ID_size=0.6,
|
|
996
1232
|
fit="off", fit_power=1, fit_se=None, fit_errors=False,
|
|
997
1233
|
fit_new=None,
|
|
998
|
-
fit_color=None, fit_lwd=None,
|
|
1234
|
+
fit_color=None, fit_lwd=None, fit_span_loess=0.75,
|
|
1235
|
+
fit_degree_loess=2, fit_family_loess="gaussian",
|
|
999
1236
|
ellipse=0, ellipse_fill=None, ellipse_color=None,
|
|
1000
1237
|
ellipse_lwd=None,
|
|
1001
1238
|
ts_unit=None, ts_agg="sum", ts_stack=False,
|
|
@@ -1010,7 +1247,7 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
1010
1247
|
rotate_x=0, rotate_y=0, scale_x=None, scale_y=None,
|
|
1011
1248
|
xlab=None, ylab=None, main=None, digits_d=None,
|
|
1012
1249
|
quiet=None,
|
|
1013
|
-
plot_errors=None):
|
|
1250
|
+
plot_errors=None, span=None):
|
|
1014
1251
|
"""Analytic view of the relationship between two numerical
|
|
1015
1252
|
variables, optionally grouped (by=). A date x displays as a
|
|
1016
1253
|
time series. Variables are strings naming columns of the
|
|
@@ -1034,14 +1271,50 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
1034
1271
|
(contour_n, contour_nbins, contour_points,
|
|
1035
1272
|
contour_legend)):
|
|
1036
1273
|
form = "contour"
|
|
1274
|
+
contour_n_user = contour_n # a matrix cell defaults to 8
|
|
1037
1275
|
contour_n = 20 if contour_n is None else contour_n
|
|
1038
1276
|
contour_nbins = 50 if contour_nbins is None else contour_nbins
|
|
1039
1277
|
contour_points = bool(contour_points)
|
|
1040
1278
|
contour_legend = bool(contour_legend)
|
|
1279
|
+
if pt_shape == "sunflower": # its earlier route, kept
|
|
1280
|
+
form, pt_shape = "sunflower", "circle"
|
|
1281
|
+
# the form is consulted before by is resolved, so a vector of by
|
|
1282
|
+
# gets the sunflower's own reason (XY.R)
|
|
1283
|
+
if form == "sunflower" and by is not None:
|
|
1284
|
+
raise ValueError(
|
|
1285
|
+
'Parameter by is not available for form="sunflower".\n\n'
|
|
1286
|
+
"A petal count is defined for one set of coordinates, not\n"
|
|
1287
|
+
"separately within each group, so overlaid groups would\n"
|
|
1288
|
+
"interleave their petals at a shared coordinate.\n"
|
|
1289
|
+
"To stratify the same two variables, use facet, which "
|
|
1290
|
+
"draws\neach group in its own panel with its own "
|
|
1291
|
+
"coordinates.")
|
|
1292
|
+
if form == "sunflower" and (isinstance(x, (list, tuple))
|
|
1293
|
+
or isinstance(y, (list, tuple))):
|
|
1294
|
+
raise ValueError(
|
|
1295
|
+
"The petal count is defined for one set of coordinates, "
|
|
1296
|
+
"not\nseparately for each series, so a vector of x or y "
|
|
1297
|
+
'variables\nis not available for form="sunflower". To '
|
|
1298
|
+
'overlay the series,\nuse form="scatter".')
|
|
1299
|
+
if isinstance(by, (list, tuple)):
|
|
1300
|
+
raise ValueError(
|
|
1301
|
+
"Only one by variable is permitted, but more than one "
|
|
1302
|
+
f"specified:\n by = {list(by)}\n\n"
|
|
1303
|
+
"The groups of by are overlaid within the panel, "
|
|
1304
|
+
"distinguished by\ncolor and optionally symbol. That one "
|
|
1305
|
+
"channel is carried by the\nfirst variable, so a second "
|
|
1306
|
+
"has no encoding left by which to\nseparate its levels.\n"
|
|
1307
|
+
"To stratify by a second variable, use facet, which draws "
|
|
1308
|
+
"each of\nits groups in a panel of its own.")
|
|
1041
1309
|
if form != "scatter" and by is not None:
|
|
1042
1310
|
raise ValueError(
|
|
1043
|
-
'by
|
|
1044
|
-
|
|
1311
|
+
f'Parameter by is not available for form="{form}".\n\n'
|
|
1312
|
+
f"A {form} plot summarizes the joint density over the "
|
|
1313
|
+
"whole\nplotting region, so summaries superimposed on it "
|
|
1314
|
+
"would obstruct\none another rather than remain "
|
|
1315
|
+
"distinguishable.\nTo stratify the same two variables, "
|
|
1316
|
+
"use facet, which draws\neach group in its own panel with "
|
|
1317
|
+
"its own density.")
|
|
1045
1318
|
if add is not None and form in ("contour", "smooth"):
|
|
1046
1319
|
raise ValueError(
|
|
1047
1320
|
'add is not active for "contour" or "smooth" plots')
|
|
@@ -1054,6 +1327,20 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
1054
1327
|
if center_line not in ("off", "mean", "median", "zero"):
|
|
1055
1328
|
raise ValueError(
|
|
1056
1329
|
'center_line: "off", "mean", "median", or "zero"')
|
|
1330
|
+
if span is not None:
|
|
1331
|
+
raise ValueError(
|
|
1332
|
+
"Parameter span has been renamed to fit_span_loess.\n\n"
|
|
1333
|
+
"It sets the span of a loess fit, so the name now says "
|
|
1334
|
+
"which\nfit it belongs to, alongside fit_degree_loess "
|
|
1335
|
+
"and fit_family_loess.")
|
|
1336
|
+
if fit_degree_loess not in (1, 2):
|
|
1337
|
+
raise ValueError("fit_degree_loess: 1 or 2")
|
|
1338
|
+
if fit_family_loess not in ("gaussian", "symmetric"):
|
|
1339
|
+
raise ValueError('fit_family_loess: "gaussian" or "symmetric"')
|
|
1340
|
+
# the loess settings travel together to every fit (R: loess(...,
|
|
1341
|
+
# span, degree, family) in .plt.fit())
|
|
1342
|
+
span = (float(fit_span_loess), int(fit_degree_loess),
|
|
1343
|
+
fit_family_loess)
|
|
1057
1344
|
if plot_errors is not None:
|
|
1058
1345
|
raise ValueError(
|
|
1059
1346
|
"Parameter plot_errors has been renamed to fit_errors. "
|
|
@@ -1091,9 +1378,6 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
1091
1378
|
raise ValueError(
|
|
1092
1379
|
"outlier flagging (MD_cut/out_cut) applies to "
|
|
1093
1380
|
'the scatter form, form="scatter"')
|
|
1094
|
-
if ts_unit is not None and facet is not None:
|
|
1095
|
-
raise ValueError(
|
|
1096
|
-
"ts_unit does not yet apply to facet plots")
|
|
1097
1381
|
if (n_row is not None or n_col is not None) \
|
|
1098
1382
|
and facet is None:
|
|
1099
1383
|
raise ValueError("n_row and n_col lay out facet panels: "
|
|
@@ -1112,14 +1396,35 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
1112
1396
|
if ts_ahead > 0:
|
|
1113
1397
|
if ts_source not in ("fable", "classic"):
|
|
1114
1398
|
raise ValueError('ts_source: "fable" or "classic"')
|
|
1115
|
-
if by is not None:
|
|
1116
|
-
raise ValueError(
|
|
1117
|
-
"
|
|
1118
|
-
"so
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
"
|
|
1122
|
-
|
|
1399
|
+
if by is not None: # XY.R: the overlay channel
|
|
1400
|
+
raise ValueError( # is not available
|
|
1401
|
+
"A forecast draws its fit, prediction interval, and "
|
|
1402
|
+
"forecast\nin the series' own colors, so the overlay "
|
|
1403
|
+
"of by= groups is not\navailable to it. To forecast "
|
|
1404
|
+
"each group, use facet=, which\nforecasts each panel "
|
|
1405
|
+
"on one scale.")
|
|
1406
|
+
|
|
1407
|
+
# for the report: the caller's own names, the parameters named in
|
|
1408
|
+
# the call (which the suggestions skip), the digits as given
|
|
1409
|
+
data_in = data
|
|
1410
|
+
digits_user = digits_d
|
|
1411
|
+
x_call = x # as written, keyword included
|
|
1412
|
+
given = {nm for nm, v in (
|
|
1413
|
+
("enhance", enhance or None), ("fill", fill), ("color", color),
|
|
1414
|
+
("fit", None if fit == "off" else fit),
|
|
1415
|
+
("out_cut", out_cut or None), ("MD_cut", MD_cut or None),
|
|
1416
|
+
("pt_shape", None if pt_shape == "circle" else pt_shape),
|
|
1417
|
+
("pt_size", None if pt_size == 1 else pt_size),
|
|
1418
|
+
("line_width", None if line_width == 1.5 else line_width),
|
|
1419
|
+
("ts_ahead", ts_ahead or None), ("ts_unit", ts_unit),
|
|
1420
|
+
("ts_agg", None if ts_agg == "sum" else ts_agg),
|
|
1421
|
+
("ts_seasons", ts_seasons), ("ts_area_fill", ts_area_fill),
|
|
1422
|
+
("ts_stack", ts_stack or None)) if v is not None}
|
|
1423
|
+
show = not resolve_quiet(quiet)
|
|
1424
|
+
|
|
1425
|
+
def say(*comps):
|
|
1426
|
+
if show:
|
|
1427
|
+
print(xc.report(*comps))
|
|
1123
1428
|
|
|
1124
1429
|
if filter is not None:
|
|
1125
1430
|
data = data.query(filter)
|
|
@@ -1150,16 +1455,106 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
1150
1455
|
return scatter_matrix(
|
|
1151
1456
|
mat_df, fit=("off" if fit in ("off", "null")
|
|
1152
1457
|
else fit),
|
|
1153
|
-
digits_d=digits_d, main=main
|
|
1458
|
+
digits_d=digits_d, main=main, form=form,
|
|
1459
|
+
contour_n=contour_n_user,
|
|
1460
|
+
contour_nbins=contour_nbins,
|
|
1461
|
+
contour_points=contour_points, smooth_bins=smooth_bins,
|
|
1462
|
+
smooth_power=smooth_power)
|
|
1463
|
+
# a vector of y on a date x: one series per variable, overlaid
|
|
1464
|
+
# and keyed by name in the legend, drawn as a by= series would
|
|
1465
|
+
# be; the value axis reports only the aggregation and time unit
|
|
1466
|
+
# (XY.R, Aug 2026)
|
|
1467
|
+
if y_multi and not x_multi and isinstance(x, str) \
|
|
1468
|
+
and x in data.columns:
|
|
1469
|
+
xd = data[x]
|
|
1470
|
+
if not pd.api.types.is_datetime64_any_dtype(xd) \
|
|
1471
|
+
and not pd.api.types.is_numeric_dtype(xd):
|
|
1472
|
+
from .date_infer import date_infer as _infer_dates
|
|
1473
|
+
try:
|
|
1474
|
+
xd = _infer_dates(xd)
|
|
1475
|
+
except Exception:
|
|
1476
|
+
pass
|
|
1477
|
+
if pd.api.types.is_datetime64_any_dtype(xd):
|
|
1478
|
+
if by is not None:
|
|
1479
|
+
raise ValueError(
|
|
1480
|
+
"A vector of y variables overlays its variables as "
|
|
1481
|
+
"the series of the\nplot, which spends the color "
|
|
1482
|
+
"dimension that by requires.\nTo display a "
|
|
1483
|
+
"grouping variable, use facet=, which panels the "
|
|
1484
|
+
"series.")
|
|
1485
|
+
fcols = ([facet] if isinstance(facet, str) else
|
|
1486
|
+
list(facet) if isinstance(facet, (list, tuple))
|
|
1487
|
+
else [])
|
|
1488
|
+
long = data[[x] + fcols + list(y)].copy()
|
|
1489
|
+
long[x] = xd
|
|
1490
|
+
long = long.melt(id_vars=[x] + fcols, value_vars=list(y),
|
|
1491
|
+
var_name="Series", value_name="Value")
|
|
1492
|
+
long["Series"] = pd.Categorical(long["Series"],
|
|
1493
|
+
categories=list(y))
|
|
1494
|
+
if ylab is None:
|
|
1495
|
+
from .plt_time import _infer_unit
|
|
1496
|
+
ylab = _ts_ylab(None, ts_unit if ts_unit is not None
|
|
1497
|
+
else _infer_unit(long[x].to_numpy()),
|
|
1498
|
+
ts_unit is not None, ts_agg)
|
|
1499
|
+
fig_l = XY(x, "Value", data=long, by="Series", facet=facet,
|
|
1500
|
+
ts_unit=ts_unit, ts_agg=ts_agg,
|
|
1501
|
+
ts_stack=ts_stack, ts_area_fill=ts_area_fill,
|
|
1502
|
+
ts_area_split=ts_area_split, pt_size=pt_size,
|
|
1503
|
+
line_width=line_width, n_row=n_row, n_col=n_col,
|
|
1504
|
+
fill=fill, xlab=xlab, ylab=ylab, main=main,
|
|
1505
|
+
digits_d=digits_d, quiet=True,
|
|
1506
|
+
rotate_x=rotate_x, rotate_y=rotate_y)
|
|
1507
|
+
fig_l.update_layout(legend_title_text="")
|
|
1508
|
+
pre_l, dn_l = xc.call_names(data_in)
|
|
1509
|
+
say(xc.suggest_series(
|
|
1510
|
+
pre_l, x, list(y), dn_l, given,
|
|
1511
|
+
"".join(f', {k}="{v}"' if isinstance(v, str)
|
|
1512
|
+
else f", {k}={v}"
|
|
1513
|
+
for k, v in (("ts_unit", ts_unit),)
|
|
1514
|
+
if v is not None), pt_size=pt_size))
|
|
1515
|
+
return fig_l
|
|
1516
|
+
|
|
1154
1517
|
if x_multi or y_multi:
|
|
1155
|
-
|
|
1518
|
+
# a vector against a categorical variable is Chart()'s dot
|
|
1519
|
+
# chart, named rather than drawn (XY.R)
|
|
1520
|
+
one = y if x_multi else x
|
|
1521
|
+
if (isinstance(one, str) and one in data.columns
|
|
1522
|
+
and not pd.api.types.is_numeric_dtype(data[one])
|
|
1523
|
+
and not pd.api.types.is_datetime64_any_dtype(
|
|
1524
|
+
data[one])):
|
|
1525
|
+
vec = x if x_multi else y
|
|
1526
|
+
vec_s = "[" + ", ".join(f"'{v}'" for v in vec) + "]"
|
|
1527
|
+
raise ValueError(
|
|
1528
|
+
"XY() requires a continuous x and a continuous y.\n"
|
|
1529
|
+
f"{one} is categorical.\n\nFor several values at each "
|
|
1530
|
+
f"level of {one}, use Chart()\nwith a vector of y "
|
|
1531
|
+
f"variables:\n Chart('{one}', y={vec_s}, form='dot')")
|
|
1532
|
+
fig_s = _apply_rotate(_xy_series(
|
|
1156
1533
|
x, y, data, x_multi, by, facet, form, n_row, n_col,
|
|
1157
1534
|
fill, transparency, pt_shape, pt_size,
|
|
1158
1535
|
fit, fit_power, fit_se, fit_lwd, span,
|
|
1159
1536
|
ellipse, ellipse_fill, ellipse_lwd,
|
|
1160
1537
|
MD_cut, out_cut,
|
|
1161
|
-
xlab, ylab, main, digits_d,
|
|
1538
|
+
xlab, ylab, main, digits_d, True),
|
|
1162
1539
|
rotate_x, rotate_y)
|
|
1540
|
+
# the report: one correlation per plotted variable, each
|
|
1541
|
+
# headed by its name (R overwrote all but the last)
|
|
1542
|
+
pre, dname = xc.call_names(data_in)
|
|
1543
|
+
names = list(x) if x_multi else list(y)
|
|
1544
|
+
other = data[y if x_multi else x]
|
|
1545
|
+
comps = [xc.suggest_scatter(pre, x, y, dname, given)]
|
|
1546
|
+
if (fit in ("off", "lm")
|
|
1547
|
+
and pd.api.types.is_numeric_dtype(other)):
|
|
1548
|
+
for nm in names:
|
|
1549
|
+
xv_ = pd.to_numeric(data[nm], errors="coerce")
|
|
1550
|
+
pair = (xv_, other) if x_multi else (other, xv_)
|
|
1551
|
+
head = (f"Variable: {nm} with {y}" if x_multi
|
|
1552
|
+
else f"Variable: {x} with {nm}")
|
|
1553
|
+
comps.append(["", head] + xc.cor_block(
|
|
1554
|
+
pair[0].to_numpy(float), pair[1].to_numpy(float),
|
|
1555
|
+
nm if x_multi else x, y if x_multi else nm))
|
|
1556
|
+
say(*comps)
|
|
1557
|
+
return fig_s
|
|
1163
1558
|
|
|
1164
1559
|
# ----- row_names: each row label identifies a single case, a
|
|
1165
1560
|
# categorical axis that belongs to Chart(form="dot")
|
|
@@ -1176,21 +1571,18 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
1176
1571
|
f" display the values of {cont} directly with Chart():\n"
|
|
1177
1572
|
f" Chart('row_names', y='{cont}', form='dot')")
|
|
1178
1573
|
|
|
1179
|
-
#
|
|
1180
|
-
#
|
|
1181
|
-
# R's stat aggregates y within bins of x (n_bins), not ported
|
|
1574
|
+
# sort and the segments_x/_y droplines served the dot plot XY()
|
|
1575
|
+
# once drew of a categorical variable, now Chart()'s
|
|
1182
1576
|
if stat is not None:
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
"variable across the levels of a categorical one, use "
|
|
1193
|
-
f"Chart():\n Chart('{cat_}', y='{cont}', stat='{stat}')")
|
|
1577
|
+
if stat not in STAT_FUN:
|
|
1578
|
+
raise ValueError("stat must be one of "
|
|
1579
|
+
+ ", ".join(f'"{k}"' for k in STAT_FUN))
|
|
1580
|
+
if (by is not None or facet is not None
|
|
1581
|
+
or isinstance(x, (list, tuple))
|
|
1582
|
+
or isinstance(y, (list, tuple))):
|
|
1583
|
+
raise ValueError(
|
|
1584
|
+
"stat plots one statistic of y at each value of x: "
|
|
1585
|
+
"no by=, facet=, or vector of variables")
|
|
1194
1586
|
for nm, v in (("sort", None if sort == "0" else sort),
|
|
1195
1587
|
("segments_x", segments_x),
|
|
1196
1588
|
("segments_y", segments_y)):
|
|
@@ -1208,10 +1600,40 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
1208
1600
|
index=data.index, name="Index")
|
|
1209
1601
|
return get_column(data, name, arg)
|
|
1210
1602
|
|
|
1603
|
+
# a keyword in the x role names no variable, so it is matched
|
|
1604
|
+
# without regard to case: .index for the run chart, .normal,
|
|
1605
|
+
# .lognormal, .exponential, .uniform for a q-q chart (XY.R)
|
|
1606
|
+
qq_key = None
|
|
1607
|
+
if (isinstance(x, str) and x.startswith(".")
|
|
1608
|
+
and x not in data.columns):
|
|
1609
|
+
key = x.lower()
|
|
1610
|
+
if key == ".index":
|
|
1611
|
+
x = ".Index"
|
|
1612
|
+
elif key in _QQ_LAB:
|
|
1613
|
+
qq_key = key
|
|
1614
|
+
else:
|
|
1615
|
+
raise ValueError(
|
|
1616
|
+
"A name in the x role that begins with a period is a "
|
|
1617
|
+
f"keyword\nfor a generated variable, but {x} is not one "
|
|
1618
|
+
"of\nthe keywords, and is not a variable in the data.\n\n"
|
|
1619
|
+
"Available keywords, matched without regard to case:\n"
|
|
1620
|
+
" .index generates the consecutive integers of a run "
|
|
1621
|
+
"chart\n " + ", ".join(_QQ_LAB) + " each generates\n"
|
|
1622
|
+
" the theoretical quantiles of the corresponding "
|
|
1623
|
+
"q-q chart")
|
|
1624
|
+
if qq_key is not None and (isinstance(y, (list, tuple))
|
|
1625
|
+
or form != "scatter"):
|
|
1626
|
+
raise ValueError(
|
|
1627
|
+
"A q-q chart plots one y variable as a scatterplot "
|
|
1628
|
+
'against its theoretical quantiles: one y, form="scatter"')
|
|
1629
|
+
|
|
1211
1630
|
index_x = isinstance(x, str) and x == ".Index"
|
|
1212
1631
|
index_y = isinstance(y, str) and y == ".Index"
|
|
1213
|
-
x_ser = _resolve(x, "x")
|
|
1214
1632
|
y_ser = _resolve(y, "y")
|
|
1633
|
+
x_ser = (pd.Series(np.nan, index=data.index, name=_QQ_LAB[qq_key])
|
|
1634
|
+
if qq_key is not None else _resolve(x, "x"))
|
|
1635
|
+
if qq_key is not None:
|
|
1636
|
+
x = _QQ_LAB[qq_key]
|
|
1215
1637
|
if index_x: # for axis labels below
|
|
1216
1638
|
x = "Index"
|
|
1217
1639
|
if index_y:
|
|
@@ -1221,6 +1643,101 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
1221
1643
|
(facet_ser, facet_name,
|
|
1222
1644
|
facet2_ser, facet2_name) = resolve_facet(data, facet, "XY")
|
|
1223
1645
|
|
|
1646
|
+
# q-q: the quantiles of y, regenerated within each group of by
|
|
1647
|
+
# and each panel of facet, so every group reads against its own
|
|
1648
|
+
# distribution; both axes then share one range (XY.R)
|
|
1649
|
+
if qq_key is not None:
|
|
1650
|
+
if not pd.api.types.is_numeric_dtype(y_ser):
|
|
1651
|
+
raise ValueError(
|
|
1652
|
+
"A q-q chart displays the distribution of a continuous "
|
|
1653
|
+
"variable,\nbut the y variable is not numeric.")
|
|
1654
|
+
grp = None
|
|
1655
|
+
for g in (by_ser, facet_ser, facet2_ser):
|
|
1656
|
+
if g is not None:
|
|
1657
|
+
gs = g.astype(str).where(g.notna())
|
|
1658
|
+
grp = gs if grp is None else grp + "\x1f" + gs
|
|
1659
|
+
yv = y_ser.to_numpy(dtype=float)
|
|
1660
|
+
if grp is None:
|
|
1661
|
+
qv = _qq_quantiles(yv, qq_key)
|
|
1662
|
+
else:
|
|
1663
|
+
qv = np.full(len(yv), np.nan)
|
|
1664
|
+
for lv in grp.dropna().unique():
|
|
1665
|
+
i = (grp == lv).to_numpy()
|
|
1666
|
+
if (~np.isnan(yv[i])).sum() >= 3:
|
|
1667
|
+
qv[i] = _qq_quantiles(yv[i], qq_key)
|
|
1668
|
+
x_ser = pd.Series(qv, index=data.index, name=x)
|
|
1669
|
+
if scale_x is None and scale_y is None:
|
|
1670
|
+
pr = pretty(float(np.nanmin(np.r_[qv, yv])),
|
|
1671
|
+
float(np.nanmax(np.r_[qv, yv])))
|
|
1672
|
+
scale_x = scale_y = (pr[0], pr[-1], len(pr) - 1)
|
|
1673
|
+
|
|
1674
|
+
# the report (x_console): ports of R's printers, built as the
|
|
1675
|
+
# display is; xy_stats() records the numbers for fig.stats
|
|
1676
|
+
pre, dname = xc.call_names(data_in)
|
|
1677
|
+
_lbls = ((getattr(data_in, "attrs", {}) or {})
|
|
1678
|
+
.get("variable_labels", {}) or {})
|
|
1679
|
+
x_lbl, y_lbl = _lbls.get(x), _lbls.get(y)
|
|
1680
|
+
dd_r = (xc.xy_digits(pd.to_numeric(y_ser, errors="coerce")
|
|
1681
|
+
.to_numpy(float), digits_user)
|
|
1682
|
+
if not isinstance(y, (list, tuple)) else 3)
|
|
1683
|
+
|
|
1684
|
+
def scatter_report(groups_, fit_stats_, md_lines_):
|
|
1685
|
+
xy_stats(groups_, x, y, fit_stats_, digits_d, by_name=by)
|
|
1686
|
+
if stat_lines is not None: # R reports the levels alone
|
|
1687
|
+
return [stat_lines]
|
|
1688
|
+
comps = [xc.suggest_scatter(pre, x_call, y, dname, given,
|
|
1689
|
+
by=by)]
|
|
1690
|
+
if md_lines_ is not None:
|
|
1691
|
+
comps.append(md_lines_)
|
|
1692
|
+
# correlation for no fit or a linear one, not with by, not
|
|
1693
|
+
# for a q-q chart (.plt.txt)
|
|
1694
|
+
if fit in ("off", "lm") and by is None and qq_key is None:
|
|
1695
|
+
for _, xg, yg in groups_:
|
|
1696
|
+
comps.append(xc.cor_block(xg, yg, x, y, x_lbl, y_lbl))
|
|
1697
|
+
# the fit that enhance= supplies is drawn, not reported (R)
|
|
1698
|
+
if (fit not in ("off", "null") and fit_stats_
|
|
1699
|
+
and not (enhance and "fit" not in given)):
|
|
1700
|
+
comps.append(xc.fit_text(fit_stats_, fit, fit_power, y,
|
|
1701
|
+
dd_r, by_name=by))
|
|
1702
|
+
return comps
|
|
1703
|
+
|
|
1704
|
+
def facet_report(xv_s):
|
|
1705
|
+
"""XY.R's summary of x per grouping variable; x's own
|
|
1706
|
+
decimals, at least 2, as X() now gives them"""
|
|
1707
|
+
sd = max(xc.max_dd(xv_s), 2)
|
|
1708
|
+
summ = ["", f"---------- Summary Statistics for {x}"]
|
|
1709
|
+
for g, nm in ((by_ser, by), (facet_ser, facet_name),
|
|
1710
|
+
(facet2_ser, facet2_name)):
|
|
1711
|
+
if g is not None:
|
|
1712
|
+
summ += [""] + xc.vbs_summary(
|
|
1713
|
+
xv_s, g.to_numpy(), nm, sd,
|
|
1714
|
+
order=category_order(g))
|
|
1715
|
+
return summ
|
|
1716
|
+
|
|
1717
|
+
# stat: the statistic of y at each value of a continuous x, the
|
|
1718
|
+
# values first reported by level of x (XY.R, .ss.numeric(y, by=x))
|
|
1719
|
+
stat_lines = None
|
|
1720
|
+
if stat is not None:
|
|
1721
|
+
for a_, b_ in ((x, y), (y, x)):
|
|
1722
|
+
if not pd.api.types.is_numeric_dtype(data[a_]):
|
|
1723
|
+
raise ValueError(
|
|
1724
|
+
"stat in XY() plots a statistic of y at each value "
|
|
1725
|
+
"of a continuous x. For a statistic of a numerical "
|
|
1726
|
+
"variable across the levels of a categorical one, "
|
|
1727
|
+
f"use Chart():\n Chart('{a_}', y='{b_}', "
|
|
1728
|
+
f"stat='{stat}')")
|
|
1729
|
+
okx = x_ser.notna()
|
|
1730
|
+
stat_lines = _stat_by_levels(
|
|
1731
|
+
pd.to_numeric(y_ser[okx]), x_ser[okx], y, x,
|
|
1732
|
+
max(xc.max_dd(y_ser.to_numpy(float)) + 1, 2))
|
|
1733
|
+
agg = (pd.to_numeric(y_ser[okx]).groupby(x_ser[okx])
|
|
1734
|
+
.agg(STAT_FUN[stat]).dropna())
|
|
1735
|
+
data = pd.DataFrame({x: agg.index.to_numpy(float),
|
|
1736
|
+
y: agg.to_numpy(float)})
|
|
1737
|
+
x_ser, y_ser = data[x], data[y]
|
|
1738
|
+
if ylab is None:
|
|
1739
|
+
ylab = f"{STAT_LBL[stat]} of {y}"
|
|
1740
|
+
|
|
1224
1741
|
# facet needs a categorical variable; a numeric one with more
|
|
1225
1742
|
# than 25 distinct values is almost surely continuous, so stop
|
|
1226
1743
|
# and point to a category conversion (XY.R ~1168). A category
|
|
@@ -1346,10 +1863,18 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
1346
1863
|
"(date x)")
|
|
1347
1864
|
|
|
1348
1865
|
# ----- ts aggregation (ts_unit/ts_agg) --------------------------
|
|
1349
|
-
|
|
1866
|
+
# facets aggregate within each panel, below
|
|
1867
|
+
ts_agg_done = is_date and ts_unit is not None and facet_ser is None
|
|
1868
|
+
if ts_agg_done:
|
|
1350
1869
|
x_ser, y_ser, by_ser, ts_unit = plt_time(
|
|
1351
1870
|
x_ser, y_ser, by_ser, ts_unit, ts_agg,
|
|
1352
1871
|
quiet=resolve_quiet(quiet))
|
|
1872
|
+
if is_date and ylab is None and facet_ser is None: # a given
|
|
1873
|
+
# ylab names the axis
|
|
1874
|
+
from .plt_time import _infer_unit
|
|
1875
|
+
ylab = _ts_ylab(
|
|
1876
|
+
y, ts_unit if ts_unit is not None
|
|
1877
|
+
else _infer_unit(x_ser.to_numpy()), ts_agg_done, ts_agg)
|
|
1353
1878
|
if is_date and (ts_stack or ts_area_fill is not None) \
|
|
1354
1879
|
and pt_size == 1:
|
|
1355
1880
|
pt_size = 0 # R: no points with areas
|
|
@@ -1377,6 +1902,87 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
1377
1902
|
add_means = (form in ("scatter", "smooth")
|
|
1378
1903
|
and facet is None)
|
|
1379
1904
|
|
|
1905
|
+
# ----- sunflower: petals where observations coincide ----------
|
|
1906
|
+
if form == "sunflower":
|
|
1907
|
+
if is_date:
|
|
1908
|
+
raise ValueError(
|
|
1909
|
+
'form="sunflower" plots two continuous variables, '
|
|
1910
|
+
"not a time series")
|
|
1911
|
+
if ellipse is True:
|
|
1912
|
+
ellipse = 0.95
|
|
1913
|
+
f_col = to_hex(get_option("fit_color", "#5C4032")
|
|
1914
|
+
if fit_color is None else fit_color)
|
|
1915
|
+
f_lwd = get_option("fit_lwd", 2) if fit_lwd is None else fit_lwd
|
|
1916
|
+
e_lvls = ([] if not ellipse else
|
|
1917
|
+
list(ellipse) if isinstance(ellipse, (list, tuple))
|
|
1918
|
+
else [ellipse])
|
|
1919
|
+
|
|
1920
|
+
def overlay(fig_, xv_, yv_, row, col):
|
|
1921
|
+
at = {} if row is None else dict(row=row, col=col)
|
|
1922
|
+
for lv in e_lvls:
|
|
1923
|
+
ex, ey = _ellipse_region(xv_, yv_, float(lv))
|
|
1924
|
+
fig_.add_trace(go.Scatter(
|
|
1925
|
+
x=ex, y=ey, mode="lines",
|
|
1926
|
+
line=dict(color=to_hex(
|
|
1927
|
+
get_option("ellipse_color", "gray20")
|
|
1928
|
+
if ellipse_color is None else ellipse_color),
|
|
1929
|
+
width=get_option("ellipse_lwd", 1)
|
|
1930
|
+
if ellipse_lwd is None else ellipse_lwd),
|
|
1931
|
+
hoverinfo="skip", showlegend=False), **at)
|
|
1932
|
+
if fit not in ("off", "null"):
|
|
1933
|
+
if fit == "loess":
|
|
1934
|
+
xs_, _, f_, _ = _loess(xv_, yv_, span)
|
|
1935
|
+
else:
|
|
1936
|
+
xs_, _, f_ = _plt_fit(xv_, yv_, fit, fit_power)
|
|
1937
|
+
fig_.add_trace(go.Scatter(
|
|
1938
|
+
x=xs_, y=f_, mode="lines",
|
|
1939
|
+
line=dict(color=f_col, width=f_lwd),
|
|
1940
|
+
hoverinfo="skip", showlegend=False), **at)
|
|
1941
|
+
|
|
1942
|
+
xv_f = x_ser.to_numpy(dtype=float)
|
|
1943
|
+
yv_f = y_ser.to_numpy(dtype=float)
|
|
1944
|
+
if facet is not None:
|
|
1945
|
+
n_lvl = len(category_order(facet_ser))
|
|
1946
|
+
n_col_sf = (int(n_col) if n_col is not None else
|
|
1947
|
+
math.ceil(n_lvl / int(n_row)) if n_row
|
|
1948
|
+
is not None else math.ceil(math.sqrt(n_lvl)))
|
|
1949
|
+
fig = sunflower_plotly(
|
|
1950
|
+
xv_f, yv_f, x if xlab is None else xlab,
|
|
1951
|
+
y if ylab is None else ylab, main,
|
|
1952
|
+
facet=facet_ser.to_numpy(),
|
|
1953
|
+
facet_order=category_order(facet_ser),
|
|
1954
|
+
facet_name=facet_name,
|
|
1955
|
+
facet2=(None if facet2_ser is None
|
|
1956
|
+
else facet2_ser.to_numpy()),
|
|
1957
|
+
facet2_order=(None if facet2_ser is None
|
|
1958
|
+
else category_order(facet2_ser)),
|
|
1959
|
+
facet2_name=facet2_name, n_col=n_col_sf,
|
|
1960
|
+
pt_size=pt_size, fill=fill, color=color,
|
|
1961
|
+
overlay=overlay,
|
|
1962
|
+
digits_d=2 if digits_d is None else digits_d,
|
|
1963
|
+
axis_fmt=axis_fmt, axis_x_pre=axis_x_pre,
|
|
1964
|
+
axis_y_pre=axis_y_pre)
|
|
1965
|
+
xy_stats([(None, xv_f, yv_f)], x, y,
|
|
1966
|
+
digits_d=2 if digits_d is None else digits_d)
|
|
1967
|
+
say(facet_report(xv_f))
|
|
1968
|
+
else:
|
|
1969
|
+
fig = sunflower_plotly(
|
|
1970
|
+
xv_f, yv_f, x if xlab is None else xlab,
|
|
1971
|
+
y if ylab is None else ylab, main, pt_size=pt_size,
|
|
1972
|
+
fill=fill, color=color, overlay=overlay,
|
|
1973
|
+
digits_d=2 if digits_d is None else digits_d,
|
|
1974
|
+
axis_fmt=axis_fmt, axis_x_pre=axis_x_pre,
|
|
1975
|
+
axis_y_pre=axis_y_pre)
|
|
1976
|
+
fs = []
|
|
1977
|
+
if fit not in ("off", "null"):
|
|
1978
|
+
if fit == "loess":
|
|
1979
|
+
xs_, ys_, f_, _ = _loess(xv_f, yv_f, span)
|
|
1980
|
+
else:
|
|
1981
|
+
xs_, ys_, f_ = _plt_fit(xv_f, yv_f, fit, fit_power)
|
|
1982
|
+
fs = [(None, fit, ys_, f_, xs_)]
|
|
1983
|
+
say(*scatter_report([(None, xv_f, yv_f)], fs, None))
|
|
1984
|
+
return _apply_rotate(fig, rotate_x, rotate_y)
|
|
1985
|
+
|
|
1380
1986
|
# ----- facet: one panel per level -----------------------------
|
|
1381
1987
|
if facet is not None:
|
|
1382
1988
|
facet2_arr = f2_order = None
|
|
@@ -1399,12 +2005,10 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
1399
2005
|
if form in ("contour", "smooth"):
|
|
1400
2006
|
if ellipse is True:
|
|
1401
2007
|
ellipse = 0.95
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
digits_d=2 if digits_d is None
|
|
1407
|
-
else digits_d)))
|
|
2008
|
+
xy_stats([(None, x_ser.to_numpy(dtype=float),
|
|
2009
|
+
y_ser.to_numpy(dtype=float))], x, y,
|
|
2010
|
+
digits_d=2 if digits_d is None else digits_d)
|
|
2011
|
+
say(facet_report(x_ser.to_numpy(dtype=float)))
|
|
1408
2012
|
return _apply_rotate(plt_contour_facet(
|
|
1409
2013
|
x_ser.to_numpy(dtype=float),
|
|
1410
2014
|
y_ser.to_numpy(dtype=float),
|
|
@@ -1434,10 +2038,54 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
1434
2038
|
axis_x_pre=axis_x_pre, axis_y_pre=axis_y_pre),
|
|
1435
2039
|
rotate_x, rotate_y)
|
|
1436
2040
|
if is_date: # time-series panels
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
2041
|
+
# ts_unit aggregates within each panel and group; the
|
|
2042
|
+
# forecast is of each panel's series (XY.R, Aug 2026)
|
|
2043
|
+
f_arr = facet_ser.to_numpy()
|
|
2044
|
+
ts_by = by_ser.to_numpy() if by_ser is not None else None
|
|
2045
|
+
agg_done = ts_unit is not None
|
|
2046
|
+
if agg_done:
|
|
2047
|
+
parts = [s_.astype(str).to_numpy() for s_ in
|
|
2048
|
+
(facet_ser, facet2_ser, by_ser)
|
|
2049
|
+
if s_ is not None]
|
|
2050
|
+
key = pd.Series(["\x1f".join(t) for t in zip(*parts)],
|
|
2051
|
+
index=x_ser.index)
|
|
2052
|
+
xa, ya, ka, ts_unit = plt_time(
|
|
2053
|
+
x_ser, y_ser, key, ts_unit, ts_agg,
|
|
2054
|
+
quiet=resolve_quiet(quiet))
|
|
2055
|
+
cols = list(zip(*[str(k).split("\x1f")
|
|
2056
|
+
for k in ka.astype(str)]))
|
|
2057
|
+
x_ser, y_ser = xa, ya
|
|
2058
|
+
f_arr = np.asarray(cols[0], dtype=object)
|
|
2059
|
+
ci = 1
|
|
2060
|
+
if facet2_ser is not None:
|
|
2061
|
+
facet2_arr = np.asarray(cols[ci], dtype=object)
|
|
2062
|
+
ci += 1
|
|
2063
|
+
if by_ser is not None:
|
|
2064
|
+
ts_by = np.asarray(cols[ci], dtype=object)
|
|
2065
|
+
f_order = [str(v) for v in category_order(facet_ser)]
|
|
2066
|
+
f_arr = np.asarray(f_arr).astype(str)
|
|
2067
|
+
if ylab is None:
|
|
2068
|
+
from .plt_time import _infer_unit
|
|
2069
|
+
ylab = _ts_ylab(y, ts_unit if ts_unit is not None
|
|
2070
|
+
else _infer_unit(x_ser.to_numpy()),
|
|
2071
|
+
agg_done, ts_agg)
|
|
2072
|
+
frcsts = {}
|
|
2073
|
+
if ts_ahead > 0:
|
|
2074
|
+
xs_all = x_ser.to_numpy()
|
|
2075
|
+
ys_all = y_ser.to_numpy(dtype=float)
|
|
2076
|
+
for i_f, lv in enumerate(f_order):
|
|
2077
|
+
mm = f_arr == lv
|
|
2078
|
+
if mm.sum() < 4:
|
|
2079
|
+
continue
|
|
2080
|
+
od = np.argsort(xs_all[mm], kind="stable")
|
|
2081
|
+
frcsts[i_f] = plt_forecast(
|
|
2082
|
+
xs_all[mm][od], ys_all[mm][od], x, y,
|
|
2083
|
+
ts_unit=ts_unit, ts_ahead=ts_ahead,
|
|
2084
|
+
ts_method=ts_method, ts_source=ts_source,
|
|
2085
|
+
ts_error=ts_error, ts_trend=ts_trend,
|
|
2086
|
+
ts_seasons=ts_seasons, ts_alpha=ts_alpha,
|
|
2087
|
+
ts_beta=ts_beta, ts_gamma=ts_gamma,
|
|
2088
|
+
ts_PI=ts_PI, digits_d=digits_d)
|
|
1441
2089
|
if transparency is None:
|
|
1442
2090
|
transparency = get_option("trans_pt_fill", 0.10)
|
|
1443
2091
|
fill0 = (get_option("pt_color", "#324E5C")
|
|
@@ -1451,7 +2099,7 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
1451
2099
|
ts_fig = _ts_facet(
|
|
1452
2100
|
x_ser.to_numpy(),
|
|
1453
2101
|
y_ser.to_numpy(dtype=float),
|
|
1454
|
-
|
|
2102
|
+
f_arr, f_order,
|
|
1455
2103
|
fill0, border0, pt_size, 1 - transparency,
|
|
1456
2104
|
x if xlab is None else xlab,
|
|
1457
2105
|
y if ylab is None else ylab,
|
|
@@ -1460,9 +2108,22 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
1460
2108
|
area_split=float(ts_area_split),
|
|
1461
2109
|
facet_name=facet_name, facet2_arr=facet2_arr,
|
|
1462
2110
|
facet2_order=f2_order,
|
|
1463
|
-
facet2_name=facet2_name, n_col=n_col_use
|
|
2111
|
+
facet2_name=facet2_name, n_col=n_col_use,
|
|
2112
|
+
by_arr=ts_by,
|
|
2113
|
+
by_order=(None if by_ser is None else
|
|
2114
|
+
[str(v) for v in category_order(by_ser)]),
|
|
2115
|
+
by_name=by, line_width=line_width,
|
|
2116
|
+
frcsts=frcsts, ts_PI=ts_PI)
|
|
1464
2117
|
if ts_n_x_tics is not None: # date-axis tick count
|
|
1465
2118
|
ts_fig.update_xaxes(nticks=int(ts_n_x_tics))
|
|
2119
|
+
if show:
|
|
2120
|
+
for i_f, fr in frcsts.items():
|
|
2121
|
+
print(f"\n{facet_name}: {f_order[i_f]}")
|
|
2122
|
+
print("\n".join(fr["report"]))
|
|
2123
|
+
print("Forecast\n--------")
|
|
2124
|
+
print(fr["forecast"].to_string(
|
|
2125
|
+
float_format=lambda v:
|
|
2126
|
+
f"{v:.{(2 if digits_d is None else digits_d) + 2}f}"))
|
|
1466
2127
|
return _apply_rotate(ts_fig, rotate_x, rotate_y)
|
|
1467
2128
|
by_arr = by_order = None
|
|
1468
2129
|
if by_ser is not None:
|
|
@@ -1472,8 +2133,7 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
1472
2133
|
if fill is None:
|
|
1473
2134
|
fills_f = ([get_option("pt_color", "#324E5C")]
|
|
1474
2135
|
if n_grp == 1
|
|
1475
|
-
else
|
|
1476
|
-
for i in range(n_grp)])
|
|
2136
|
+
else by_colors(n_grp))
|
|
1477
2137
|
else:
|
|
1478
2138
|
fills_f = (list(fill)
|
|
1479
2139
|
if isinstance(fill, (list, tuple))
|
|
@@ -1489,28 +2149,11 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
1489
2149
|
else [fit_se]) if lv]
|
|
1490
2150
|
if ellipse is True:
|
|
1491
2151
|
ellipse = 0.95
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
x, y, digits_d=dd_s)))
|
|
1498
|
-
# summary table per grouping variable, ~ XY.R's
|
|
1499
|
-
# .vbs_summary_table pivot block
|
|
1500
|
-
print(f"\n---------- Summary Statistics for {x}")
|
|
1501
|
-
if by_ser is not None:
|
|
1502
|
-
print()
|
|
1503
|
-
print(facet_summary(xv_s, by_ser.to_numpy(),
|
|
1504
|
-
by, dd_s))
|
|
1505
|
-
print()
|
|
1506
|
-
print(facet_summary(xv_s, facet_ser.to_numpy(),
|
|
1507
|
-
facet_name, dd_s))
|
|
1508
|
-
if facet2_ser is not None:
|
|
1509
|
-
print()
|
|
1510
|
-
print(facet_summary(xv_s,
|
|
1511
|
-
facet2_ser.to_numpy(),
|
|
1512
|
-
facet2_name, dd_s))
|
|
1513
|
-
return _apply_rotate(_xy_facet(
|
|
2152
|
+
xv_s = x_ser.to_numpy(dtype=float)
|
|
2153
|
+
xy_stats([(None, xv_s, y_ser.to_numpy(dtype=float))],
|
|
2154
|
+
x, y, digits_d=2 if digits_d is None else digits_d)
|
|
2155
|
+
say(facet_report(xv_s))
|
|
2156
|
+
fig_f = _apply_rotate(_xy_facet(
|
|
1514
2157
|
x_ser.to_numpy(dtype=float),
|
|
1515
2158
|
y_ser.to_numpy(dtype=float),
|
|
1516
2159
|
by_arr, by_order,
|
|
@@ -1535,6 +2178,9 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
1535
2178
|
facet2_arr=facet2_arr, facet2_order=f2_order,
|
|
1536
2179
|
facet2_name=facet2_name, n_col=n_col_use),
|
|
1537
2180
|
rotate_x, rotate_y)
|
|
2181
|
+
if qq_key is not None:
|
|
2182
|
+
_qq_reference(fig_f, fit_color, fit_lwd)
|
|
2183
|
+
return fig_f
|
|
1538
2184
|
|
|
1539
2185
|
# ----- groups and colors --------------------------------------
|
|
1540
2186
|
xv = x_ser.to_numpy() if is_date \
|
|
@@ -1592,8 +2238,7 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
1592
2238
|
n_grp = len(groups)
|
|
1593
2239
|
if fill is None:
|
|
1594
2240
|
fills = ([get_option("pt_color", "#324E5C")] if n_grp == 1
|
|
1595
|
-
else
|
|
1596
|
-
for i in range(n_grp)])
|
|
2241
|
+
else by_colors(n_grp))
|
|
1597
2242
|
else:
|
|
1598
2243
|
fills = list(fill) if isinstance(fill, (list, tuple)) \
|
|
1599
2244
|
else [fill]
|
|
@@ -1676,7 +2321,7 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
1676
2321
|
if fit == "loess": # R: bands for lm and loess
|
|
1677
2322
|
xs, ys_s, f, se_f = _loess(xg, yg, span)
|
|
1678
2323
|
fit_lines.append({"name": nm, "x": xs, "y": f})
|
|
1679
|
-
fit_stats.append((nm, fit, ys_s, f))
|
|
2324
|
+
fit_stats.append((nm, fit, ys_s, f, xs))
|
|
1680
2325
|
if fit_errors:
|
|
1681
2326
|
err_lines.append((xs, ys_s, f))
|
|
1682
2327
|
for lv in se_levels:
|
|
@@ -1690,7 +2335,7 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
1690
2335
|
okf = np.isfinite(f) # exp/log back-transform NaN
|
|
1691
2336
|
fit_lines.append({"name": nm, "x": xs[okf],
|
|
1692
2337
|
"y": f[okf]})
|
|
1693
|
-
fit_stats.append((nm, fit, ys_s[okf], f[okf]))
|
|
2338
|
+
fit_stats.append((nm, fit, ys_s[okf], f[okf], xs[okf]))
|
|
1694
2339
|
if fit_errors:
|
|
1695
2340
|
err_lines.append((xs[okf], ys_s[okf], f[okf]))
|
|
1696
2341
|
if fit == "lm":
|
|
@@ -1740,11 +2385,7 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
1740
2385
|
axis_fmt=axis_fmt, axis_x_pre=axis_x_pre,
|
|
1741
2386
|
axis_y_pre=axis_y_pre)
|
|
1742
2387
|
_apply_rotate(fig, rotate_x, rotate_y)
|
|
1743
|
-
|
|
1744
|
-
print("\n".join(xy_stats(groups, x, y, fit_stats,
|
|
1745
|
-
digits_d)))
|
|
1746
|
-
if md_lines is not None:
|
|
1747
|
-
print("\n" + "\n".join(md_lines))
|
|
2388
|
+
say(*scatter_report(groups, fit_stats, md_lines))
|
|
1748
2389
|
return fig
|
|
1749
2390
|
|
|
1750
2391
|
# ----- forecast (date x, ts_source="classic") -------------------
|
|
@@ -1767,7 +2408,7 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
1767
2408
|
frcst["y_lwr"], frcst["y_upr"]] if frcst else []))
|
|
1768
2409
|
if scale_y is not None: # explicit y scale
|
|
1769
2410
|
axT2 = np.linspace(float(scale_y[0]), float(scale_y[1]),
|
|
1770
|
-
int(scale_y[2]))
|
|
2411
|
+
int(scale_y[2]) + 1) # n intervals
|
|
1771
2412
|
else:
|
|
1772
2413
|
axT2 = pretty(float(np.nanmin(ys_all)),
|
|
1773
2414
|
float(np.nanmax(ys_all)))
|
|
@@ -1783,7 +2424,7 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
1783
2424
|
if scale_x is not None: # explicit x scale
|
|
1784
2425
|
axT1 = np.linspace(float(scale_x[0]),
|
|
1785
2426
|
float(scale_x[1]),
|
|
1786
|
-
int(scale_x[2]))
|
|
2427
|
+
int(scale_x[2]) + 1) # n intervals
|
|
1787
2428
|
else:
|
|
1788
2429
|
axT1 = pretty(float(np.nanmin(xs_all)),
|
|
1789
2430
|
float(np.nanmax(xs_all)))
|
|
@@ -1832,9 +2473,7 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
1832
2473
|
_apply_rotate(fig, rotate_x, rotate_y)
|
|
1833
2474
|
if add_means:
|
|
1834
2475
|
_add_means(fig, xv, yv)
|
|
1835
|
-
|
|
1836
|
-
print("\n".join(xy_stats(groups, x, y, fit_stats,
|
|
1837
|
-
digits_d)))
|
|
2476
|
+
say(*scatter_report(groups, fit_stats, md_lines))
|
|
1838
2477
|
return fig
|
|
1839
2478
|
|
|
1840
2479
|
fig = plt_plotly(
|
|
@@ -1911,6 +2550,8 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
1911
2550
|
|
|
1912
2551
|
if add_means: # enhance: mean crosshair
|
|
1913
2552
|
_add_means(fig, xv, yv)
|
|
2553
|
+
if qq_key is not None:
|
|
2554
|
+
_qq_reference(fig, fit_color, fit_lwd)
|
|
1914
2555
|
|
|
1915
2556
|
if scale_x is not None: # explicit axis ranges
|
|
1916
2557
|
fig.update_xaxes(range=[float(scale_x[0]),
|
|
@@ -1968,28 +2609,41 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
1968
2609
|
dash="dash"),
|
|
1969
2610
|
showlegend=True))
|
|
1970
2611
|
|
|
2612
|
+
# the series displays: suggestions (with the forecast report
|
|
2613
|
+
# and table when one is made), the run analysis of a run chart
|
|
2614
|
+
ser_extra = "".join(
|
|
2615
|
+
f", {k}={v!r}" if isinstance(v, str) else f", {k}={v}"
|
|
2616
|
+
for k, v in (("by", by), ("ts_unit", ts_unit),
|
|
2617
|
+
("ts_ahead", ts_ahead or None))
|
|
2618
|
+
if v is not None).replace("'", '"')
|
|
1971
2619
|
if frcst is not None:
|
|
1972
2620
|
_forecast_traces(fig, frcst, ts_PI)
|
|
1973
|
-
if
|
|
2621
|
+
if show:
|
|
1974
2622
|
for ln in frcst["report"]:
|
|
1975
2623
|
print(ln)
|
|
1976
2624
|
if ts_fitted: # observed vs model-fit table
|
|
1977
2625
|
print("\n".join(_ts_fitted_lines(
|
|
1978
2626
|
frcst, yv, x, y, ts_unit, digits_d)))
|
|
1979
|
-
|
|
2627
|
+
say(xc.suggest_series(pre, x, y, dname, given,
|
|
2628
|
+
ser_extra, pt_size=pt_size))
|
|
2629
|
+
print("Forecast\n--------")
|
|
1980
2630
|
print(frcst["forecast"].to_string(
|
|
1981
2631
|
float_format=lambda v: f"{v:.{digits_d + 2}f}"))
|
|
1982
|
-
elif
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
2632
|
+
elif is_date:
|
|
2633
|
+
say(xc.suggest_series(pre, x, y, dname, given, ser_extra,
|
|
2634
|
+
pt_size=pt_size))
|
|
2635
|
+
elif index_x: # run chart: R's run analysis
|
|
2636
|
+
if run_lines is None:
|
|
2637
|
+
_, run_lines = _run_analysis(yv, digits_d, False)
|
|
2638
|
+
say(xc.suggest_series(pre, ".Index", y, dname, given,
|
|
2639
|
+
ser_extra, run=True, pt_size=pt_size),
|
|
2640
|
+
xc.run_summary(yv, dd_r), run_lines[1:])
|
|
2641
|
+
else:
|
|
2642
|
+
say(*scatter_report(groups, fit_stats, md_lines))
|
|
2643
|
+
if fit_new is not None and fit in _FIT_NEW_OK and show:
|
|
1986
2644
|
print("\n".join(_fit_new_table(
|
|
1987
2645
|
groups, fit, fit_power, fit_new, x, y, digits_d)))
|
|
1988
|
-
if
|
|
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:
|
|
2646
|
+
if (jitter_x > 0 or jitter_y > 0) and show:
|
|
1993
2647
|
print("\nSome Parameter values (can be manually set)")
|
|
1994
2648
|
print("-" * 55)
|
|
1995
2649
|
print(f"size: {float(pt_size):.2f} "
|
|
@@ -2004,4 +2658,6 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
2004
2658
|
|
|
2005
2659
|
|
|
2006
2660
|
# font_size= scales all text of the returned figure
|
|
2007
|
-
|
|
2661
|
+
from .stats_out import attach_stats as _attach_stats # noqa: E402
|
|
2662
|
+
from .style import with_theme as _with_theme # noqa: E402
|
|
2663
|
+
XY = font_scaled(_attach_stats(_with_theme(XY), capture=True))
|