lessPython 0.3.0__py3-none-any.whl → 0.3.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lessPy/ANOVA.py +70 -11
- lessPy/Chart.py +49 -4
- lessPy/Correlation.py +39 -10
- lessPy/Flows.py +3 -3
- lessPy/Logit.py +14 -10
- lessPy/Regression.py +22 -18
- lessPy/X.py +3 -2
- lessPy/XY.py +528 -91
- lessPy/bc_items_plotly.py +2 -2
- lessPy/bc_plotly.py +6 -5
- lessPy/bubble_plotly.py +16 -15
- lessPy/corCFA.py +7 -3
- lessPy/corProp.py +7 -3
- lessPy/corReflect.py +7 -3
- lessPy/corReorder.py +7 -3
- lessPy/corScree.py +10 -6
- lessPy/dn_plotly.py +9 -9
- lessPy/dot_plotly.py +9 -8
- lessPy/freq_poly_plotly.py +6 -6
- lessPy/hier_plotly.py +5 -5
- lessPy/hs_plotly.py +7 -7
- lessPy/pie_plotly.py +1 -1
- lessPy/pivot.py +39 -9
- lessPy/plotly_utils.py +90 -26
- lessPy/plt_add.py +2 -7
- lessPy/plt_contour.py +6 -6
- lessPy/plt_contour_facet.py +3 -3
- lessPy/plt_mat_plotly.py +11 -10
- lessPy/plt_plotly.py +34 -18
- lessPy/plt_smooth.py +4 -4
- lessPy/prob_norm.py +5 -1
- lessPy/prob_tcut.py +5 -1
- lessPy/prob_znorm.py +5 -1
- lessPy/profile_plotly.py +5 -4
- lessPy/radar_plotly.py +4 -3
- lessPy/simCImean.py +5 -1
- lessPy/simCLT.py +5 -1
- lessPy/simFlips.py +5 -1
- lessPy/simMeans.py +5 -1
- lessPy/style.py +8 -1
- lessPy/sunflower_plotly.py +6 -5
- lessPy/ttest.py +13 -9
- lessPy/utils.py +103 -0
- lessPy/vbs_plotly.py +23 -22
- {lesspython-0.3.0.dist-info → lesspython-0.3.1.dist-info}/METADATA +8 -1
- lesspython-0.3.1.dist-info/RECORD +89 -0
- {lesspython-0.3.0.dist-info → lesspython-0.3.1.dist-info}/WHEEL +1 -1
- lesspython-0.3.0.dist-info/RECORD +0 -89
- {lesspython-0.3.0.dist-info → lesspython-0.3.1.dist-info}/licenses/LICENSE +0 -0
- {lesspython-0.3.0.dist-info → lesspython-0.3.1.dist-info}/top_level.txt +0 -0
lessPy/XY.py
CHANGED
|
@@ -73,7 +73,7 @@ from .plotly_utils import (
|
|
|
73
73
|
BASE_COLORS, as_plotly_color, axis_format, axis_num,
|
|
74
74
|
facet_fig, facet_panels, finish_facet,
|
|
75
75
|
get_tick_fmt, legend_style, make_trans, plot_border,
|
|
76
|
-
plotly_style, square_layout,
|
|
76
|
+
plotly_style, square_layout, marks, to_hex, x_grid,
|
|
77
77
|
font_scaled, by_colors)
|
|
78
78
|
from .utils import (
|
|
79
79
|
STAT_FUN, STAT_LBL, category_order, get_column, get_option,
|
|
@@ -213,14 +213,18 @@ def _loess_rows(xs, q_pt, span, degree, rw):
|
|
|
213
213
|
w = (d == 0).astype(float)
|
|
214
214
|
w = w * rw
|
|
215
215
|
nz = np.flatnonzero(w)
|
|
216
|
-
|
|
216
|
+
# x in units of the window, so the powers of a large-valued x,
|
|
217
|
+
# such as dates as days, keep pinv well conditioned; the slope
|
|
218
|
+
# back in units of x
|
|
219
|
+
sc = h if h > 0 else 1.0
|
|
220
|
+
xc = (xs[nz] - q_pt) / sc
|
|
217
221
|
X = np.column_stack([xc ** k for k in range(degree + 1)])
|
|
218
222
|
XtW = X.T * w[nz]
|
|
219
223
|
B = np.linalg.pinv(XtW @ X) @ XtW
|
|
220
224
|
val = np.zeros(n)
|
|
221
225
|
slope = np.zeros(n)
|
|
222
226
|
val[nz] = B[0]
|
|
223
|
-
slope[nz] = B[1]
|
|
227
|
+
slope[nz] = B[1] / sc
|
|
224
228
|
return val, slope
|
|
225
229
|
|
|
226
230
|
|
|
@@ -306,6 +310,11 @@ def _loess(xv, yv, span, degree=2, family="gaussian"):
|
|
|
306
310
|
(1 - (r / cmad) ** 2) ** 2, 1.0))
|
|
307
311
|
l2 = (L ** 2).sum(axis=1)
|
|
308
312
|
dof = n - 2 * np.trace(L) + l2.sum() # tr[(I-L)'(I-L)]
|
|
313
|
+
# the t quantile of the band takes the fit's lookup degrees of
|
|
314
|
+
# freedom, delta1^2 / delta2. R analog: predict.loess()$df
|
|
315
|
+
IL = np.eye(n) - L
|
|
316
|
+
M = IL.T @ IL
|
|
317
|
+
_loess.df = dof ** 2 / (M * M.T).sum() # tr(M^2)
|
|
309
318
|
return xs, ys, f, np.sqrt(res @ res / dof * l2)
|
|
310
319
|
|
|
311
320
|
|
|
@@ -378,7 +387,7 @@ def _qq_reference(fig, fit_color, fit_lwd):
|
|
|
378
387
|
panels[key] = (min(lo, v.min()), max(hi, v.max()))
|
|
379
388
|
refs = [go.Scatter(x=[lo, hi], y=[lo, hi], mode="lines",
|
|
380
389
|
xaxis=xa, yaxis=ya,
|
|
381
|
-
line=dict(color=
|
|
390
|
+
line=dict(color=as_plotly_color(get_option(
|
|
382
391
|
"fit_color", "#5C4032")
|
|
383
392
|
if fit_color is None else fit_color),
|
|
384
393
|
width=1.5 if fit_lwd is None else fit_lwd),
|
|
@@ -411,17 +420,137 @@ def _ts_ylab(y_name, unit, aggregated, ts_agg):
|
|
|
411
420
|
def _se_band(xs, ys, f, level):
|
|
412
421
|
"""SE band polygon about a least-squares line: fit +/- t * SE
|
|
413
422
|
of the conditional mean. R analog: plt.main.R se bands, from
|
|
414
|
-
predict(se=TRUE) and qt(prb,
|
|
423
|
+
predict(se=TRUE) and qt(prb, df), df the residual df. None
|
|
424
|
+
when the band is undefined, fewer than 3 points or a single x
|
|
425
|
+
value, where R's se is NaN and no band is drawn."""
|
|
415
426
|
n = len(xs)
|
|
427
|
+
if n < 3 or np.ptp(xs) == 0:
|
|
428
|
+
return None
|
|
416
429
|
mse = ((ys - f) ** 2).sum() / (n - 2)
|
|
417
430
|
xbar = xs.mean()
|
|
418
431
|
sxx = ((xs - xbar) ** 2).sum()
|
|
419
432
|
se = np.sqrt(mse * (1 / n + (xs - xbar) ** 2 / sxx))
|
|
420
|
-
tq = sps.t.ppf((1 + level) / 2, n -
|
|
433
|
+
tq = sps.t.ppf((1 + level) / 2, n - 2) # the residual df
|
|
421
434
|
return (np.concatenate([xs, xs[::-1]]),
|
|
422
435
|
np.concatenate([f + tq * se, (f - tq * se)[::-1]]))
|
|
423
436
|
|
|
424
437
|
|
|
438
|
+
def _plt_bins(x_ser, y_ser, x, y, n_bins, stat, by, facet, pt_size,
|
|
439
|
+
ylab, digits_d, show):
|
|
440
|
+
"""Bin x into n_bins equal intervals, R's cut() with the range
|
|
441
|
+
extended by 1/1000, and take the mean or median of y in each.
|
|
442
|
+
Returns the midpoints and statistics of the nonempty bins as the
|
|
443
|
+
new x and y, the y label, each point's pixel diameter, and the
|
|
444
|
+
bin counts, and prints R's two tables. R analog: .plt.bins()"""
|
|
445
|
+
st = "mean" if stat is None else stat
|
|
446
|
+
if st not in ("mean", "median"):
|
|
447
|
+
raise ValueError(
|
|
448
|
+
"stat can only take two values for binning: mean and "
|
|
449
|
+
"median")
|
|
450
|
+
if by is not None or facet is not None:
|
|
451
|
+
raise ValueError("n_bins bins one x variable: no by or facet")
|
|
452
|
+
if (not pd.api.types.is_numeric_dtype(x_ser)
|
|
453
|
+
or not pd.api.types.is_numeric_dtype(y_ser)):
|
|
454
|
+
raise ValueError("n_bins: both variables must be numeric")
|
|
455
|
+
n_bins = int(n_bins)
|
|
456
|
+
if n_bins < 2:
|
|
457
|
+
raise ValueError("Need at least two bins set by n_bins")
|
|
458
|
+
xv = x_ser.to_numpy(dtype=float)
|
|
459
|
+
yv = y_ser.to_numpy(dtype=float)
|
|
460
|
+
ok_x = np.isfinite(xv)
|
|
461
|
+
if len(np.unique(xv[ok_x])) < n_bins and show:
|
|
462
|
+
print(f"You specified {n_bins} bins, but only "
|
|
463
|
+
f"{len(np.unique(xv[ok_x]))} values of {x}\n"
|
|
464
|
+
"Empty bins will appear in the solution\n")
|
|
465
|
+
lo, hi = float(np.nanmin(xv)), float(np.nanmax(xv))
|
|
466
|
+
dx = hi - lo
|
|
467
|
+
bns = np.linspace(lo, hi, n_bins + 1)
|
|
468
|
+
bns[0], bns[-1] = lo - dx / 1000, hi + dx / 1000
|
|
469
|
+
mid = bns[:-1] + (bns[1:] - bns[:-1]) / 2
|
|
470
|
+
n = np.zeros(n_bins, dtype=int)
|
|
471
|
+
m = np.full(n_bins, np.nan)
|
|
472
|
+
for i in range(n_bins):
|
|
473
|
+
ys = yv[(xv > bns[i]) & (xv <= bns[i + 1])]
|
|
474
|
+
ys = ys[np.isfinite(ys)]
|
|
475
|
+
n[i] = len(ys)
|
|
476
|
+
if len(ys):
|
|
477
|
+
m[i] = ys.mean() if st == "mean" else np.median(ys)
|
|
478
|
+
|
|
479
|
+
if show:
|
|
480
|
+
def f_(v, d):
|
|
481
|
+
return f"{v:.{d}f}"
|
|
482
|
+
dx_ = 0 if np.all(xv[ok_x] == np.round(xv[ok_x])) else digits_d
|
|
483
|
+
ok_y = np.isfinite(yv)
|
|
484
|
+
dy_ = 0 if np.all(yv[ok_y] == np.round(yv[ok_y])) else digits_d
|
|
485
|
+
rows = [("n", str(ok_x.sum()), str(ok_y.sum())),
|
|
486
|
+
("n.miss", str((~ok_x).sum()), str((~ok_y).sum())),
|
|
487
|
+
("min", f_(np.nanmin(xv), dx_), f_(np.nanmin(yv), dy_)),
|
|
488
|
+
("max", f_(np.nanmax(xv), dx_), f_(np.nanmax(yv), dy_)),
|
|
489
|
+
("mean", f_(np.nanmean(xv), digits_d),
|
|
490
|
+
f_(np.nanmean(yv), digits_d))]
|
|
491
|
+
w1 = max(len(x), max(len(r[1]) for r in rows))
|
|
492
|
+
w2 = max(len(y), max(len(r[2]) for r in rows))
|
|
493
|
+
print("\nSummary Stats\n")
|
|
494
|
+
print(f"{'':7} {x:>{w1}} {y:>{w2}}")
|
|
495
|
+
for r in rows:
|
|
496
|
+
print(f"{r[0]:7} {r[1]:>{w1}} {r[2]:>{w2}}")
|
|
497
|
+
brk = [f"{b:.{digits_d}f}" for b in bns]
|
|
498
|
+
labs = [f"({brk[i]},{brk[i + 1]}]" for i in range(n_bins)]
|
|
499
|
+
labs[0] = "[" + labs[0][1:]
|
|
500
|
+
tbl = pd.DataFrame({
|
|
501
|
+
"bin": labs, "n": n,
|
|
502
|
+
"midpt": [f"{v:.{digits_d}f}" for v in mid],
|
|
503
|
+
st: ["NaN" if not np.isfinite(v) else f"{v:.{digits_d}f}"
|
|
504
|
+
for v in m]}, index=range(1, n_bins + 1))
|
|
505
|
+
print(f"\n{st} of {y} for levels of {x}\n")
|
|
506
|
+
print(tbl.to_string())
|
|
507
|
+
|
|
508
|
+
keep = n > 0
|
|
509
|
+
if isinstance(pt_size, (int, float)) and pt_size != 1:
|
|
510
|
+
cex = np.full(keep.sum(), float(pt_size))
|
|
511
|
+
else: # R: max size about 10
|
|
512
|
+
cex = (n[keep] / (n.max() / 10)) ** 0.6
|
|
513
|
+
x_new = pd.Series(mid[keep], name=x_ser.name)
|
|
514
|
+
y_new = pd.Series(m[keep], name=y_ser.name)
|
|
515
|
+
ylab = f"{st} of {y}" if ylab is None else ylab
|
|
516
|
+
return x_new, y_new, ylab, cex * 7.25, n[keep]
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def _as_years(s):
|
|
520
|
+
"""A run of consecutive integers from 10 to 9999 as the dates of
|
|
521
|
+
Jan 1 of those years, else None. R analog: XY.R ~733"""
|
|
522
|
+
if (not pd.api.types.is_numeric_dtype(s)
|
|
523
|
+
or pd.api.types.is_bool_dtype(s) or len(s) < 2):
|
|
524
|
+
return None
|
|
525
|
+
v = s.to_numpy(dtype=float)
|
|
526
|
+
if (np.isnan(v).any() or (v != np.round(v)).any()
|
|
527
|
+
or not (np.diff(v) == 1).all()
|
|
528
|
+
or v.min() < 10 or v.max() > 9999):
|
|
529
|
+
return None
|
|
530
|
+
out = pd.Series(pd.to_datetime([f"{int(t):04d}-01-01" for t in v]),
|
|
531
|
+
index=s.index, name=s.name)
|
|
532
|
+
return out
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def _ellipse_levels(ellipse):
|
|
536
|
+
"""The confidence levels of the data ellipses: True is 0.95, a
|
|
537
|
+
number one level, a list several, as R's ellipse=c(.3,.5,.9).
|
|
538
|
+
False, None, or 0 is none."""
|
|
539
|
+
if ellipse is True:
|
|
540
|
+
return [0.95]
|
|
541
|
+
if ellipse is None or ellipse is False:
|
|
542
|
+
return []
|
|
543
|
+
lvls = (list(ellipse) if isinstance(ellipse, (list, tuple,
|
|
544
|
+
np.ndarray))
|
|
545
|
+
else [ellipse])
|
|
546
|
+
lvls = [float(lv) for lv in lvls if lv]
|
|
547
|
+
for lv in lvls:
|
|
548
|
+
if not 0 < lv < 1:
|
|
549
|
+
raise ValueError("ellipse is the confidence level of "
|
|
550
|
+
"a data ellipse, between 0 and 1")
|
|
551
|
+
return lvls
|
|
552
|
+
|
|
553
|
+
|
|
425
554
|
def _ellipse_region(xv, yv, level, npoints=100):
|
|
426
555
|
"""Bivariate-normal data ellipse, the Murdoch and Chow
|
|
427
556
|
construction of the ellipse package that XY() cites.
|
|
@@ -442,7 +571,7 @@ def _xy_facet(xv, yv, by_arr, by_order, facet_arr, facet_order,
|
|
|
442
571
|
ellipse, ellipse_fill, ellipse_color, ellipse_lwd,
|
|
443
572
|
x_lab, y_lab, main, digits_d,
|
|
444
573
|
facet2_arr=None, facet2_order=None,
|
|
445
|
-
facet2_name=None, n_col=1):
|
|
574
|
+
facet2_name=None, n_col=1, fit_errors=False):
|
|
446
575
|
"""One scatter panel per facet level on shared axes,
|
|
447
576
|
following the faceted-histogram conventions (_hs_facet):
|
|
448
577
|
first level on the top panel, strip labels, legend
|
|
@@ -473,9 +602,10 @@ def _xy_facet(xv, yv, by_arr, by_order, facet_arr, facet_order,
|
|
|
473
602
|
if fit == "loess":
|
|
474
603
|
xs, ys_s, f, se_f = _loess(xg, yg, span)
|
|
475
604
|
fit_lines.append({"name": gname, "x": xs,
|
|
476
|
-
"y": f, "g": g
|
|
605
|
+
"y": f, "g": g,
|
|
606
|
+
"err": (xs, ys_s, f)})
|
|
477
607
|
for lv in se_levels:
|
|
478
|
-
tq = sps.t.ppf((1 + lv) / 2,
|
|
608
|
+
tq = sps.t.ppf((1 + lv) / 2, _loess.df)
|
|
479
609
|
se_polys.append((
|
|
480
610
|
np.concatenate([xs, xs[::-1]]),
|
|
481
611
|
np.concatenate(
|
|
@@ -485,14 +615,17 @@ def _xy_facet(xv, yv, by_arr, by_order, facet_arr, facet_order,
|
|
|
485
615
|
xs, ys_s, f = _plt_fit(xg, yg, fit, fit_power)
|
|
486
616
|
okf = np.isfinite(f)
|
|
487
617
|
fit_lines.append({"name": gname, "x": xs[okf],
|
|
488
|
-
"y": f[okf], "g": g
|
|
618
|
+
"y": f[okf], "g": g,
|
|
619
|
+
"err": (xs[okf], ys_s[okf],
|
|
620
|
+
f[okf])})
|
|
489
621
|
if fit == "lm":
|
|
490
622
|
for lv in se_levels:
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
623
|
+
band = _se_band(xs, ys_s, f, lv)
|
|
624
|
+
if band is not None:
|
|
625
|
+
se_polys.append(band)
|
|
626
|
+
if len(xg) >= 3:
|
|
627
|
+
for lv in _ellipse_levels(ellipse):
|
|
628
|
+
ellipses.append((g, *_ellipse_region(xg, yg, lv)))
|
|
496
629
|
panels.append((pts, fit_lines, se_polys, ellipses))
|
|
497
630
|
for fl in fit_lines:
|
|
498
631
|
xs_all.append(fl["x"]); ys_all.append(fl["y"])
|
|
@@ -517,22 +650,22 @@ def _xy_facet(xv, yv, by_arr, by_order, facet_arr, facet_order,
|
|
|
517
650
|
row, col = pos[i]
|
|
518
651
|
for g, (gname, xg, yg) in enumerate(pts):
|
|
519
652
|
fig.add_trace(go.Scatter(
|
|
520
|
-
x=xg, y=yg,
|
|
653
|
+
x=xg, y=yg,
|
|
521
654
|
name=None if gname is None else str(gname),
|
|
522
655
|
legendgroup=(None if gname is None
|
|
523
656
|
else str(gname)),
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
sizemode="diameter",
|
|
657
|
+
**marks(shape, g, "markers", dict(
|
|
658
|
+
size=px, sizemode="diameter",
|
|
527
659
|
color=make_trans(fills[g], pt_opacity),
|
|
528
660
|
opacity=1,
|
|
529
|
-
line=dict(color=
|
|
661
|
+
line=dict(color=as_plotly_color(fills[g]),
|
|
662
|
+
width=1))),
|
|
530
663
|
hoverinfo="x+y" + ("+name" if has_groups else ""),
|
|
531
664
|
showlegend=has_groups and i == 0,
|
|
532
665
|
), row=row, col=col)
|
|
533
666
|
for g, ex, ey in ellipses:
|
|
534
|
-
edge = (
|
|
535
|
-
else
|
|
667
|
+
edge = (as_plotly_color(fills[g]) if has_groups
|
|
668
|
+
else as_plotly_color(ellipse_color))
|
|
536
669
|
fig.add_trace(go.Scatter(
|
|
537
670
|
x=ex, y=ey, mode="lines",
|
|
538
671
|
line=dict(color=edge, width=ellipse_lwd),
|
|
@@ -546,6 +679,18 @@ def _xy_facet(xv, yv, by_arr, by_order, facet_arr, facet_order,
|
|
|
546
679
|
fillcolor=as_plotly_color(se_fill),
|
|
547
680
|
hoverinfo="skip", showlegend=False,
|
|
548
681
|
), row=row, col=col)
|
|
682
|
+
# fit_errors: each point's residual segment to its fitted
|
|
683
|
+
# value, as on the single panel (R plt.main.R ~1403)
|
|
684
|
+
for fl in (fit_lines if fit_errors else []):
|
|
685
|
+
ex, ey = [], []
|
|
686
|
+
for xe, ye, fe in zip(*fl["err"]):
|
|
687
|
+
ex += [xe, xe, None]
|
|
688
|
+
ey += [ye, fe, None]
|
|
689
|
+
fig.add_trace(go.Scatter(
|
|
690
|
+
x=ex, y=ey, mode="lines",
|
|
691
|
+
line=dict(color="#822823", width=1),
|
|
692
|
+
hoverinfo="skip", showlegend=False,
|
|
693
|
+
), row=row, col=col)
|
|
549
694
|
for fl in fit_lines:
|
|
550
695
|
if len(fl["x"]) < 2:
|
|
551
696
|
continue
|
|
@@ -555,8 +700,8 @@ def _xy_facet(xv, yv, by_arr, by_order, facet_arr, facet_order,
|
|
|
555
700
|
name="Fit" if single else f"Fit: {fl['name']}",
|
|
556
701
|
legendgroup=("fit" if single
|
|
557
702
|
else str(fl["name"])),
|
|
558
|
-
line=dict(color=(
|
|
559
|
-
else
|
|
703
|
+
line=dict(color=(as_plotly_color(fit_color) if single
|
|
704
|
+
else as_plotly_color(fills[fl["g"]])),
|
|
560
705
|
width=fit_lwd),
|
|
561
706
|
hoverinfo="skip",
|
|
562
707
|
showlegend=i == 0,
|
|
@@ -614,7 +759,7 @@ def _series_overlay(V, names, shared, on, fills, shape, pt_size,
|
|
|
614
759
|
xs, ys_s, f, se_f = _loess(xg, yg, span)
|
|
615
760
|
fit_lines.append((xs, f))
|
|
616
761
|
for lv in se_levels:
|
|
617
|
-
tq = sps.t.ppf((1 + lv) / 2,
|
|
762
|
+
tq = sps.t.ppf((1 + lv) / 2, _loess.df)
|
|
618
763
|
se_polys.append((
|
|
619
764
|
np.concatenate([xs, xs[::-1]]),
|
|
620
765
|
np.concatenate([f + tq * se_f,
|
|
@@ -625,9 +770,12 @@ def _series_overlay(V, names, shared, on, fills, shape, pt_size,
|
|
|
625
770
|
fit_lines.append((xs[okf], f[okf]))
|
|
626
771
|
if fit == "lm":
|
|
627
772
|
for lv in se_levels:
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
773
|
+
band = _se_band(xs, ys_s, f, lv)
|
|
774
|
+
if band is not None:
|
|
775
|
+
se_polys.append(band)
|
|
776
|
+
if len(xg) >= 3:
|
|
777
|
+
for lv in _ellipse_levels(ellipse):
|
|
778
|
+
ellipses.append(_ellipse_region(xg, yg, lv))
|
|
631
779
|
art.append((xg, yg, fit_lines, se_polys, ellipses))
|
|
632
780
|
xs_all += [xg] + [fl[0] for fl in fit_lines] \
|
|
633
781
|
+ [ex for ex, _ in ellipses]
|
|
@@ -649,7 +797,7 @@ def _series_overlay(V, names, shared, on, fills, shape, pt_size,
|
|
|
649
797
|
for ex, ey in ellipses:
|
|
650
798
|
fig.add_trace(go.Scatter(
|
|
651
799
|
x=ex, y=ey, mode="lines",
|
|
652
|
-
line=dict(color=
|
|
800
|
+
line=dict(color=as_plotly_color(fills[g]),
|
|
653
801
|
width=ellipse_lwd),
|
|
654
802
|
fill="toself",
|
|
655
803
|
fillcolor=as_plotly_color(ellipse_fill),
|
|
@@ -660,14 +808,14 @@ def _series_overlay(V, names, shared, on, fills, shape, pt_size,
|
|
|
660
808
|
fillcolor=as_plotly_color(se_fill),
|
|
661
809
|
hoverinfo="skip", showlegend=False))
|
|
662
810
|
fig.add_trace(go.Scatter(
|
|
663
|
-
x=xg, y=yg,
|
|
811
|
+
x=xg, y=yg, name=str(nm),
|
|
664
812
|
legendgroup=str(nm),
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
813
|
+
**marks(shape, g, "markers", dict(
|
|
814
|
+
size=px, sizemode="diameter",
|
|
815
|
+
color=make_trans(fills[g], pt_opacity),
|
|
816
|
+
opacity=1,
|
|
817
|
+
line=dict(color=as_plotly_color(fills[g]),
|
|
818
|
+
width=1))),
|
|
671
819
|
hoverinfo="x+y+name", showlegend=True))
|
|
672
820
|
for fx_l, fy_l in fit_lines:
|
|
673
821
|
if len(fx_l) < 2:
|
|
@@ -675,7 +823,7 @@ def _series_overlay(V, names, shared, on, fills, shape, pt_size,
|
|
|
675
823
|
fig.add_trace(go.Scatter(
|
|
676
824
|
x=fx_l, y=fy_l, mode="lines",
|
|
677
825
|
legendgroup=str(nm),
|
|
678
|
-
line=dict(color=
|
|
826
|
+
line=dict(color=as_plotly_color(fills[g]),
|
|
679
827
|
width=fit_lwd),
|
|
680
828
|
hoverinfo="skip", showlegend=False))
|
|
681
829
|
|
|
@@ -684,14 +832,14 @@ def _series_overlay(V, names, shared, on, fills, shape, pt_size,
|
|
|
684
832
|
pad = 0.04 * (axT2[-1] - axT2[0])
|
|
685
833
|
ax_y["range"] = [axT2[0] - pad, axT2[-1] + pad]
|
|
686
834
|
ax_y.update(showgrid=True,
|
|
687
|
-
gridcolor=
|
|
835
|
+
gridcolor=as_plotly_color(style_opts["grid_col"]),
|
|
688
836
|
gridwidth=1, griddash="dot")
|
|
689
837
|
fig.update_layout(
|
|
690
838
|
xaxis=ax_x, yaxis=ax_y,
|
|
691
839
|
shapes=x_grid(axT1) + plot_border(),
|
|
692
840
|
template=None,
|
|
693
|
-
plot_bgcolor=
|
|
694
|
-
paper_bgcolor=
|
|
841
|
+
plot_bgcolor=as_plotly_color(style_opts["panel_fill"]),
|
|
842
|
+
paper_bgcolor=as_plotly_color(style_opts["window_fill"]),
|
|
695
843
|
legend=legend_style("", style_opts),
|
|
696
844
|
**square_layout(main=bool(main)))
|
|
697
845
|
if main:
|
|
@@ -770,7 +918,7 @@ def _facet_series(V, names, shared, on, facet_arr, facet_order,
|
|
|
770
918
|
color=make_trans(fills[g],
|
|
771
919
|
pt_opacity),
|
|
772
920
|
opacity=1,
|
|
773
|
-
line=dict(color=
|
|
921
|
+
line=dict(color=as_plotly_color(fills[g]),
|
|
774
922
|
width=1)),
|
|
775
923
|
hoverinfo="x+y+name",
|
|
776
924
|
showlegend=i == 0,
|
|
@@ -781,7 +929,7 @@ def _facet_series(V, names, shared, on, facet_arr, facet_order,
|
|
|
781
929
|
fig.add_trace(go.Scatter(
|
|
782
930
|
x=fx_l, y=fy_l, mode="lines",
|
|
783
931
|
legendgroup=str(names[g]),
|
|
784
|
-
line=dict(color=
|
|
932
|
+
line=dict(color=as_plotly_color(fills[g]),
|
|
785
933
|
width=fit_lwd),
|
|
786
934
|
hoverinfo="skip", showlegend=False,
|
|
787
935
|
), row=row, col=col)
|
|
@@ -860,6 +1008,7 @@ def _xy_series(x, y, data, x_multi, by, facet, form, n_row,
|
|
|
860
1008
|
if ylab is None else ylab)
|
|
861
1009
|
|
|
862
1010
|
k = len(names)
|
|
1011
|
+
fill = _expand_palette(fill, k)
|
|
863
1012
|
if isinstance(fill, (list, tuple)):
|
|
864
1013
|
fills = [fill[i % len(fill)] for i in range(k)]
|
|
865
1014
|
elif fill is not None:
|
|
@@ -951,8 +1100,10 @@ def _ts_facet(xv, yv, facet_arr, facet_order, fill0, border0,
|
|
|
951
1100
|
"axL2": axis_format(axT2, digits_d)}
|
|
952
1101
|
|
|
953
1102
|
ypart = f"%{{y:{fmt2}}}" if fmt2 else "%{y}"
|
|
954
|
-
|
|
955
|
-
|
|
1103
|
+
xpart = ("Date: %{x|%Y-%m-%d}"
|
|
1104
|
+
if np.issubdtype(np.asarray(xv).dtype, np.datetime64)
|
|
1105
|
+
else f"{x_lab}: %{{x}}")
|
|
1106
|
+
hover = f"{xpart}<br>{y_lab.strip()}: {ypart}<extra></extra>"
|
|
956
1107
|
|
|
957
1108
|
style_opts = plotly_style()
|
|
958
1109
|
fig = facet_fig(n_row_g, n_col)
|
|
@@ -993,10 +1144,13 @@ def _ts_facet(xv, yv, facet_arr, facet_order, fill0, border0,
|
|
|
993
1144
|
sizemode="diameter",
|
|
994
1145
|
color=make_trans(f_c, pt_opacity),
|
|
995
1146
|
opacity=1,
|
|
996
|
-
line=dict(color=
|
|
1147
|
+
line=dict(color=as_plotly_color(b_c), width=1))
|
|
997
1148
|
if "markers" in mode_pts or line_width <= 0
|
|
998
1149
|
else None),
|
|
999
|
-
line=dict(color=
|
|
1150
|
+
line=dict(color=as_plotly_color(
|
|
1151
|
+
style_opts["segment_color"]
|
|
1152
|
+
if nm is None and get_option("segment_color_set")
|
|
1153
|
+
else b_c), width=line_width),
|
|
1000
1154
|
name=nm, legendgroup=nm,
|
|
1001
1155
|
hovertemplate=hv,
|
|
1002
1156
|
showlegend=nm is not None and i == 0,
|
|
@@ -1016,7 +1170,7 @@ def _ts_facet(xv, yv, facet_arr, facet_order, fill0, border0,
|
|
|
1016
1170
|
pad = 0.04 * (axT2[-1] - axT2[0])
|
|
1017
1171
|
fig.update_yaxes(range=[axT2[0] - pad, axT2[-1] + pad])
|
|
1018
1172
|
fig.update_xaxes(showgrid=True,
|
|
1019
|
-
gridcolor=
|
|
1173
|
+
gridcolor=as_plotly_color(style_opts["grid_col"]),
|
|
1020
1174
|
gridwidth=1)
|
|
1021
1175
|
if main:
|
|
1022
1176
|
title_size = round(16 * get_option("main_size", 1))
|
|
@@ -1026,6 +1180,16 @@ def _ts_facet(xv, yv, facet_arr, facet_order, fill0, border0,
|
|
|
1026
1180
|
return fig
|
|
1027
1181
|
|
|
1028
1182
|
|
|
1183
|
+
def _expand_palette(col, n):
|
|
1184
|
+
"""A palette name, such as fill="blues", as n of its colors,
|
|
1185
|
+
one per series or by group, as R's fill="blues". Any other
|
|
1186
|
+
value is returned unchanged."""
|
|
1187
|
+
from .Chart import _color_range, _palette_names
|
|
1188
|
+
if isinstance(col, str) and col in _palette_names():
|
|
1189
|
+
return _color_range(col, n)
|
|
1190
|
+
return col
|
|
1191
|
+
|
|
1192
|
+
|
|
1029
1193
|
def _area_color(col, opacity):
|
|
1030
1194
|
"""Area fill color: "on" is the violin_fill option; an
|
|
1031
1195
|
explicit #RRGGBBAA keeps its own alpha; otherwise the point
|
|
@@ -1122,14 +1286,14 @@ def _outlier_traces(fig, xv, yv, out_idx, labels, fill0,
|
|
|
1122
1286
|
sizemode="diameter",
|
|
1123
1287
|
color=make_trans(fill0, pt_opacity),
|
|
1124
1288
|
opacity=1,
|
|
1125
|
-
line=dict(color=
|
|
1289
|
+
line=dict(color=as_plotly_color(fill0), width=1.5)),
|
|
1126
1290
|
hoverinfo="x+y", showlegend=False))
|
|
1127
1291
|
font_sz = max(9, round(float(ID_size) * 14))
|
|
1128
1292
|
for i in out_idx:
|
|
1129
1293
|
fig.add_annotation(
|
|
1130
1294
|
x=xv[i], y=yv[i], text=str(labels[i]),
|
|
1131
1295
|
showarrow=False, yshift=-12,
|
|
1132
|
-
font=dict(color=
|
|
1296
|
+
font=dict(color=as_plotly_color(ID_color), size=font_sz,
|
|
1133
1297
|
family="Arial"))
|
|
1134
1298
|
|
|
1135
1299
|
|
|
@@ -1216,16 +1380,17 @@ def _ts_fitted_lines(frcst, yv, x_name, y_name, ts_unit, digits_d):
|
|
|
1216
1380
|
def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
1217
1381
|
n_row=None, n_col=None,
|
|
1218
1382
|
form="scatter", stat=None, sort="0", show_runs=False,
|
|
1219
|
-
center_line=
|
|
1383
|
+
center_line=None,
|
|
1220
1384
|
contour_n=None, contour_nbins=None, contour_points=None,
|
|
1221
1385
|
contour_legend=None,
|
|
1222
1386
|
smooth_points=100, smooth_size=1, smooth_power=0.25,
|
|
1223
|
-
smooth_bins=128,
|
|
1387
|
+
smooth_bins=128, n_bins=None,
|
|
1224
1388
|
enhance=False,
|
|
1225
1389
|
segments_x=None, segments_y=None,
|
|
1226
1390
|
add=None, x1=None, y1=None, x2=None, y2=None,
|
|
1227
1391
|
fill=None, color=None, transparency=None,
|
|
1228
1392
|
pt_size=1, pt_shape="circle", line_width=1.5,
|
|
1393
|
+
radius=None, power=None,
|
|
1229
1394
|
jitter_x=None, jitter_y=None,
|
|
1230
1395
|
MD_cut=0, out_cut=0, out_shape="circle-open", out_size=1,
|
|
1231
1396
|
ID=None, ID_color="gray50", ID_size=0.6,
|
|
@@ -1276,6 +1441,10 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
1276
1441
|
contour_nbins = 50 if contour_nbins is None else contour_nbins
|
|
1277
1442
|
contour_points = bool(contour_points)
|
|
1278
1443
|
contour_legend = bool(contour_legend)
|
|
1444
|
+
# fill="on" is the violin_fill color, as R (XY.R ~332)
|
|
1445
|
+
if isinstance(fill, str) and fill == "on" or (
|
|
1446
|
+
isinstance(fill, (list, tuple)) and "on" in fill):
|
|
1447
|
+
fill = get_option("violin_fill", "#7485975A")
|
|
1279
1448
|
if pt_shape == "sunflower": # its earlier route, kept
|
|
1280
1449
|
form, pt_shape = "sunflower", "circle"
|
|
1281
1450
|
# the form is consulted before by is resolved, so a vector of by
|
|
@@ -1324,7 +1493,7 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
1324
1493
|
"no facet=")
|
|
1325
1494
|
if axis_fmt not in ("K", ",", ".", ""):
|
|
1326
1495
|
raise ValueError('axis_fmt: "K", ",", ".", or ""')
|
|
1327
|
-
if center_line not in ("off", "mean", "median", "zero"):
|
|
1496
|
+
if center_line not in (None, "off", "mean", "median", "zero"):
|
|
1328
1497
|
raise ValueError(
|
|
1329
1498
|
'center_line: "off", "mean", "median", or "zero"')
|
|
1330
1499
|
if span is not None:
|
|
@@ -1347,7 +1516,7 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
1347
1516
|
"The segments it draws join each point to the fitted line, "
|
|
1348
1517
|
"so they are the errors of the fit, and the name now says "
|
|
1349
1518
|
"which fit they belong to, as fit, fit_color, and fit_se do.")
|
|
1350
|
-
if center_line
|
|
1519
|
+
if center_line not in (None, "off") and facet is not None:
|
|
1351
1520
|
raise ValueError(
|
|
1352
1521
|
"center_line draws on a single panel: no facet=")
|
|
1353
1522
|
if fit_errors:
|
|
@@ -1357,9 +1526,6 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
1357
1526
|
"line: specify fit=")
|
|
1358
1527
|
if form != "scatter":
|
|
1359
1528
|
raise ValueError('fit_errors applies to form="scatter"')
|
|
1360
|
-
if facet is not None:
|
|
1361
|
-
raise ValueError(
|
|
1362
|
-
"fit_errors draws on a single panel: no facet=")
|
|
1363
1529
|
if (scale_x is not None or scale_y is not None) \
|
|
1364
1530
|
and facet is not None:
|
|
1365
1531
|
raise ValueError(
|
|
@@ -1384,7 +1550,7 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
1384
1550
|
"specify facet=")
|
|
1385
1551
|
if ts_agg not in ("sum", "mean"):
|
|
1386
1552
|
raise ValueError('ts_agg: "sum" or "mean"')
|
|
1387
|
-
if ts_stack and by is None:
|
|
1553
|
+
if ts_stack and by is None and not isinstance(y, (list, tuple)):
|
|
1388
1554
|
raise ValueError(
|
|
1389
1555
|
"ts_stack stacks multiple time series: specify by=")
|
|
1390
1556
|
if (by is not None and ts_area_fill is not None
|
|
@@ -1464,6 +1630,31 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
1464
1630
|
# and keyed by name in the legend, drawn as a by= series would
|
|
1465
1631
|
# be; the value axis reports only the aggregation and time unit
|
|
1466
1632
|
# (XY.R, Aug 2026)
|
|
1633
|
+
# a vector of y over .Index: one run chart per variable, overlaid,
|
|
1634
|
+
# each numbered by row, as the date x above (XY.R run, n.ycol > 1)
|
|
1635
|
+
if (y_multi and not x_multi and isinstance(x, str)
|
|
1636
|
+
and x.lower() == ".index" and x not in data.columns):
|
|
1637
|
+
if by is not None or facet is not None:
|
|
1638
|
+
raise ValueError(
|
|
1639
|
+
"A vector of y variables over .Index overlays its "
|
|
1640
|
+
"variables as the\nseries of the run chart, so by and "
|
|
1641
|
+
"facet do not apply.")
|
|
1642
|
+
long = data[list(y)].copy()
|
|
1643
|
+
long[".Index"] = np.arange(1, len(data) + 1)
|
|
1644
|
+
long = long.melt(id_vars=[".Index"], value_vars=list(y),
|
|
1645
|
+
var_name="Series", value_name="Value")
|
|
1646
|
+
long["Series"] = pd.Categorical(long["Series"],
|
|
1647
|
+
categories=list(y))
|
|
1648
|
+
fig_l = XY(".Index", "Value", data=long, by="Series",
|
|
1649
|
+
center_line=("off" if center_line is None
|
|
1650
|
+
else center_line),
|
|
1651
|
+
pt_size=pt_size, line_width=line_width, fill=fill,
|
|
1652
|
+
xlab=xlab, ylab=("" if ylab is None else ylab),
|
|
1653
|
+
main=main, digits_d=digits_d, quiet=True,
|
|
1654
|
+
rotate_x=rotate_x, rotate_y=rotate_y)
|
|
1655
|
+
fig_l.update_layout(legend_title_text="")
|
|
1656
|
+
return fig_l
|
|
1657
|
+
|
|
1467
1658
|
if y_multi and not x_multi and isinstance(x, str) \
|
|
1468
1659
|
and x in data.columns:
|
|
1469
1660
|
xd = data[x]
|
|
@@ -1596,6 +1787,8 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
1596
1787
|
# chart: synthesize it rather than looking up a column
|
|
1597
1788
|
def _resolve(name, arg):
|
|
1598
1789
|
if isinstance(name, str) and name == ".Index":
|
|
1790
|
+
if ".Index" in data.columns: # numbered within series
|
|
1791
|
+
return data[".Index"].rename("Index")
|
|
1599
1792
|
return pd.Series(range(1, len(data) + 1),
|
|
1600
1793
|
index=data.index, name="Index")
|
|
1601
1794
|
return get_column(data, name, arg)
|
|
@@ -1640,6 +1833,30 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
1640
1833
|
y = "Index"
|
|
1641
1834
|
|
|
1642
1835
|
by_ser = get_column(data, by, "by") if by is not None else None
|
|
1836
|
+
|
|
1837
|
+
# ----- bubbles: pt_size names a variable that sizes each point,
|
|
1838
|
+
# its area by size^power, the largest radius inches across, as
|
|
1839
|
+
# R's bubble plot (XY.R ~489, plt.plotly bubbles)
|
|
1840
|
+
size_ser = size_name = None
|
|
1841
|
+
if isinstance(pt_size, str):
|
|
1842
|
+
if (facet is not None or form != "scatter"
|
|
1843
|
+
or index_x or qq_key is not None):
|
|
1844
|
+
raise ValueError(
|
|
1845
|
+
f"pt_size='{pt_size}' sizes the points of a "
|
|
1846
|
+
"scatterplot by a variable, a bubble plot,\nwhich "
|
|
1847
|
+
"draws on one panel: no facet, form, run chart, or "
|
|
1848
|
+
"q-q chart")
|
|
1849
|
+
size_ser = get_column(data, pt_size, "pt_size")
|
|
1850
|
+
if not pd.api.types.is_numeric_dtype(size_ser):
|
|
1851
|
+
raise TypeError(
|
|
1852
|
+
f"pt_size='{pt_size}' sizes the bubbles by the values "
|
|
1853
|
+
"of a numeric variable")
|
|
1854
|
+
size_name, pt_size = pt_size, 1
|
|
1855
|
+
elif radius is not None or power is not None:
|
|
1856
|
+
raise ValueError(
|
|
1857
|
+
"radius and power size the bubbles of a bubble plot, "
|
|
1858
|
+
"where pt_size names a\nnumeric variable, such as "
|
|
1859
|
+
"pt_size='Pre'")
|
|
1643
1860
|
(facet_ser, facet_name,
|
|
1644
1861
|
facet2_ser, facet2_name) = resolve_facet(data, facet, "XY")
|
|
1645
1862
|
|
|
@@ -1716,6 +1933,17 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
1716
1933
|
|
|
1717
1934
|
# stat: the statistic of y at each value of a continuous x, the
|
|
1718
1935
|
# values first reported by level of x (XY.R, .ss.numeric(y, by=x))
|
|
1936
|
+
# n_bins: the mean (or median) of y within each of n_bins equal
|
|
1937
|
+
# bins of x, at the bin midpoints, the point sized by the count
|
|
1938
|
+
# and the points joined (R .plt.bins)
|
|
1939
|
+
bin_diam = bin_n = None
|
|
1940
|
+
if n_bins is not None:
|
|
1941
|
+
x_ser, y_ser, ylab, bin_diam, bin_n = _plt_bins(
|
|
1942
|
+
x_ser, y_ser, x, y, n_bins, stat, by, facet, pt_size,
|
|
1943
|
+
ylab, 3 if digits_d is None else digits_d, show)
|
|
1944
|
+
data = pd.DataFrame({x: x_ser, y: y_ser})
|
|
1945
|
+
stat = None
|
|
1946
|
+
|
|
1719
1947
|
stat_lines = None
|
|
1720
1948
|
if stat is not None:
|
|
1721
1949
|
for a_, b_ in ((x, y), (y, x)):
|
|
@@ -1776,6 +2004,16 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
1776
2004
|
except (ValueError, TypeError): # not a date column
|
|
1777
2005
|
pass
|
|
1778
2006
|
|
|
2007
|
+
# consecutive integer years become dates, Jan 1 of each, as XY.R
|
|
2008
|
+
# ~733, so they drive the time-series display and a forecast
|
|
2009
|
+
yrs = None if index_x else _as_years(x_ser)
|
|
2010
|
+
if yrs is not None:
|
|
2011
|
+
x_ser = yrs
|
|
2012
|
+
if show:
|
|
2013
|
+
print(f"\n>>> Note: Variable {x} assumed to be annual years\n"
|
|
2014
|
+
"Converted to a type Date variable\n"
|
|
2015
|
+
"If forecasting, there will be no seasonality.\n")
|
|
2016
|
+
|
|
1779
2017
|
is_date = pd.api.types.is_datetime64_any_dtype(x_ser)
|
|
1780
2018
|
# XY() displays the relationship of two continuous variables; a
|
|
1781
2019
|
# categorical x or y belongs to another view, so name its call
|
|
@@ -1819,10 +2057,12 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
1819
2057
|
y_ser = y_ser.fillna(ts_NA)
|
|
1820
2058
|
|
|
1821
2059
|
used = [s for s in (x_ser, y_ser, by_ser, facet_ser,
|
|
1822
|
-
facet2_ser)
|
|
2060
|
+
facet2_ser, size_ser)
|
|
1823
2061
|
if s is not None]
|
|
1824
2062
|
keep = ~pd.concat(used, axis=1).isna().any(axis=1)
|
|
1825
2063
|
x_ser, y_ser = x_ser[keep], y_ser[keep]
|
|
2064
|
+
if size_ser is not None:
|
|
2065
|
+
size_ser = size_ser[keep]
|
|
1826
2066
|
if by_ser is not None:
|
|
1827
2067
|
by_ser = by_ser[keep]
|
|
1828
2068
|
if facet_ser is not None:
|
|
@@ -1830,11 +2070,50 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
1830
2070
|
if facet2_ser is not None:
|
|
1831
2071
|
facet2_ser = facet2_ser[keep]
|
|
1832
2072
|
|
|
2073
|
+
if is_date and size_ser is not None:
|
|
2074
|
+
raise ValueError(
|
|
2075
|
+
f"pt_size='{size_name}' sizes the points of a scatterplot, "
|
|
2076
|
+
"not of a time series")
|
|
1833
2077
|
if is_date and form != "scatter":
|
|
1834
2078
|
raise ValueError(
|
|
1835
2079
|
f'form="{form}" describes the joint density of two '
|
|
1836
2080
|
"numerical variables, not a time series")
|
|
1837
2081
|
|
|
2082
|
+
if is_date and show:
|
|
2083
|
+
# the line connects the points in date order within each
|
|
2084
|
+
# series. A date repeated within a series, unless ts_unit
|
|
2085
|
+
# aggregates it, means more than one series is present;
|
|
2086
|
+
# otherwise rows out of order are reordered for the plot
|
|
2087
|
+
# below, the data unchanged. R analog: XY.R date.var check
|
|
2088
|
+
g_nm = [nm for nm, sr in ((by, by_ser), (facet_name, facet_ser),
|
|
2089
|
+
(facet2_name, facet2_ser))
|
|
2090
|
+
if sr is not None]
|
|
2091
|
+
g_ser = [sr.astype(str) for sr in (by_ser, facet_ser, facet2_ser)
|
|
2092
|
+
if sr is not None]
|
|
2093
|
+
key = (pd.concat(g_ser, axis=1).agg("\x1f".join, axis=1)
|
|
2094
|
+
if g_ser else pd.Series("", index=x_ser.index))
|
|
2095
|
+
in_grp = (f" within each level of {', '.join(g_nm)}"
|
|
2096
|
+
if g_nm else "")
|
|
2097
|
+
rep = pd.DataFrame({"k": key, "x": x_ser}).groupby(
|
|
2098
|
+
["k", "x"]).size()
|
|
2099
|
+
if ts_unit is not None:
|
|
2100
|
+
pass # aggregated by period: any order
|
|
2101
|
+
elif (rep > 1).any():
|
|
2102
|
+
print(f"\n>>> Note\nThe dates of {x} repeat{in_grp}, up "
|
|
2103
|
+
f"to {int(rep.max())} times, so\n the data hold more "
|
|
2104
|
+
"than one series, and the line connects\n points "
|
|
2105
|
+
"from different series. Separate the series with by "
|
|
2106
|
+
"or facet,\n or select one with filter.\n")
|
|
2107
|
+
elif not all(grp.is_monotonic_increasing for _, grp in
|
|
2108
|
+
x_ser.groupby(key, sort=False)):
|
|
2109
|
+
ob = g_nm + [x]
|
|
2110
|
+
ob = (f"'{x}'" if len(ob) == 1
|
|
2111
|
+
else "[" + ", ".join(f"'{v}'" for v in ob) + "]")
|
|
2112
|
+
print(f"\n>>> Note\nThe rows are not in order of {x}{in_grp},"
|
|
2113
|
+
f"\n so they are sorted by {x} for this plot. The "
|
|
2114
|
+
"data frame is unchanged.\n To sort it, enter:\n"
|
|
2115
|
+
f" d = lp.order_by(d, by={ob})\n")
|
|
2116
|
+
|
|
1838
2117
|
if is_date: # time series: date order
|
|
1839
2118
|
od = x_ser.sort_values(kind="stable").index
|
|
1840
2119
|
x_ser, y_ser = x_ser.loc[od], y_ser.loc[od]
|
|
@@ -1844,10 +2123,15 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
1844
2123
|
facet_ser = facet_ser.loc[od]
|
|
1845
2124
|
if facet2_ser is not None:
|
|
1846
2125
|
facet2_ser = facet2_ser.loc[od]
|
|
1847
|
-
if
|
|
1848
|
-
raise
|
|
1849
|
-
"
|
|
1850
|
-
"
|
|
2126
|
+
if ellipse is not False and ellipse:
|
|
2127
|
+
raise ValueError(
|
|
2128
|
+
"An ellipse describes the joint distribution of two "
|
|
2129
|
+
"continuous variables,\nnot a time series: no ellipse "
|
|
2130
|
+
"with a date x")
|
|
2131
|
+
if fit != "off" and facet_ser is not None:
|
|
2132
|
+
raise ValueError(
|
|
2133
|
+
"fit on a time series draws on a single panel: no "
|
|
2134
|
+
"facet=")
|
|
1851
2135
|
if MD_cut > 0 or out_cut > 0:
|
|
1852
2136
|
raise ValueError(
|
|
1853
2137
|
"outlier flagging (MD_cut/out_cut) applies to a "
|
|
@@ -1857,10 +2141,14 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
1857
2141
|
"forecasting (ts_ahead) requires a date variable "
|
|
1858
2142
|
"for x")
|
|
1859
2143
|
|
|
1860
|
-
if not is_date and
|
|
2144
|
+
if not is_date and not index_x and (
|
|
2145
|
+
ts_stack or ts_area_fill is not None):
|
|
1861
2146
|
raise ValueError(
|
|
1862
2147
|
"ts_stack and ts_area_fill apply to a time series "
|
|
1863
|
-
"(date x)")
|
|
2148
|
+
"(date x) or a run chart (x='.Index')")
|
|
2149
|
+
if index_x and ts_stack:
|
|
2150
|
+
raise ValueError(
|
|
2151
|
+
"ts_stack stacks several time series over a date x")
|
|
1864
2152
|
|
|
1865
2153
|
# ----- ts aggregation (ts_unit/ts_agg) --------------------------
|
|
1866
2154
|
# facets aggregate within each panel, below
|
|
@@ -1875,7 +2163,7 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
1875
2163
|
ylab = _ts_ylab(
|
|
1876
2164
|
y, ts_unit if ts_unit is not None
|
|
1877
2165
|
else _infer_unit(x_ser.to_numpy()), ts_agg_done, ts_agg)
|
|
1878
|
-
if is_date and (ts_stack or ts_area_fill is not None) \
|
|
2166
|
+
if (is_date or index_x) and (ts_stack or ts_area_fill is not None) \
|
|
1879
2167
|
and pt_size == 1:
|
|
1880
2168
|
pt_size = 0 # R: no points with areas
|
|
1881
2169
|
|
|
@@ -1910,8 +2198,8 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
1910
2198
|
"not a time series")
|
|
1911
2199
|
if ellipse is True:
|
|
1912
2200
|
ellipse = 0.95
|
|
1913
|
-
f_col =
|
|
1914
|
-
|
|
2201
|
+
f_col = as_plotly_color(get_option("fit_color", "#5C4032")
|
|
2202
|
+
if fit_color is None else fit_color)
|
|
1915
2203
|
f_lwd = get_option("fit_lwd", 2) if fit_lwd is None else fit_lwd
|
|
1916
2204
|
e_lvls = ([] if not ellipse else
|
|
1917
2205
|
list(ellipse) if isinstance(ellipse, (list, tuple))
|
|
@@ -1923,7 +2211,7 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
1923
2211
|
ex, ey = _ellipse_region(xv_, yv_, float(lv))
|
|
1924
2212
|
fig_.add_trace(go.Scatter(
|
|
1925
2213
|
x=ex, y=ey, mode="lines",
|
|
1926
|
-
line=dict(color=
|
|
2214
|
+
line=dict(color=as_plotly_color(
|
|
1927
2215
|
get_option("ellipse_color", "gray20")
|
|
1928
2216
|
if ellipse_color is None else ellipse_color),
|
|
1929
2217
|
width=get_option("ellipse_lwd", 1)
|
|
@@ -2037,7 +2325,9 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
2037
2325
|
n_col=n_col_use, axis_fmt=axis_fmt,
|
|
2038
2326
|
axis_x_pre=axis_x_pre, axis_y_pre=axis_y_pre),
|
|
2039
2327
|
rotate_x, rotate_y)
|
|
2040
|
-
|
|
2328
|
+
# time-series panels; a filled run chart draws its panels
|
|
2329
|
+
# the same way, on the row index (XY.R lattice area)
|
|
2330
|
+
if is_date or (index_x and ts_area_fill is not None):
|
|
2041
2331
|
# ts_unit aggregates within each panel and group; the
|
|
2042
2332
|
# forecast is of each panel's series (XY.R, Aug 2026)
|
|
2043
2333
|
f_arr = facet_ser.to_numpy()
|
|
@@ -2064,7 +2354,7 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
2064
2354
|
ts_by = np.asarray(cols[ci], dtype=object)
|
|
2065
2355
|
f_order = [str(v) for v in category_order(facet_ser)]
|
|
2066
2356
|
f_arr = np.asarray(f_arr).astype(str)
|
|
2067
|
-
if ylab is None:
|
|
2357
|
+
if ylab is None and is_date:
|
|
2068
2358
|
from .plt_time import _infer_unit
|
|
2069
2359
|
ylab = _ts_ylab(y, ts_unit if ts_unit is not None
|
|
2070
2360
|
else _infer_unit(x_ser.to_numpy()),
|
|
@@ -2142,8 +2432,8 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
2142
2432
|
for i in range(n_grp)]
|
|
2143
2433
|
if transparency is None:
|
|
2144
2434
|
transparency = get_option("trans_pt_fill", 0.10)
|
|
2145
|
-
if fit_se is None:
|
|
2146
|
-
fit_se = 0.95 if by is None else 0
|
|
2435
|
+
if fit_se is None: # residuals, not a band
|
|
2436
|
+
fit_se = 0 if fit_errors else (0.95 if by is None else 0)
|
|
2147
2437
|
se_lv = [lv for lv in (
|
|
2148
2438
|
fit_se if isinstance(fit_se, (list, tuple))
|
|
2149
2439
|
else [fit_se]) if lv]
|
|
@@ -2176,7 +2466,8 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
2176
2466
|
y if ylab is None else ylab,
|
|
2177
2467
|
main, 2 if digits_d is None else digits_d,
|
|
2178
2468
|
facet2_arr=facet2_arr, facet2_order=f2_order,
|
|
2179
|
-
facet2_name=facet2_name, n_col=n_col_use
|
|
2469
|
+
facet2_name=facet2_name, n_col=n_col_use,
|
|
2470
|
+
fit_errors=fit_errors),
|
|
2180
2471
|
rotate_x, rotate_y)
|
|
2181
2472
|
if qq_key is not None:
|
|
2182
2473
|
_qq_reference(fig_f, fit_color, fit_lwd)
|
|
@@ -2194,6 +2485,27 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
2194
2485
|
groups = [(nm, xv[bv == nm], yv[bv == nm])
|
|
2195
2486
|
for nm in by_order]
|
|
2196
2487
|
|
|
2488
|
+
# bubble diameters in pixels: 2 * (v / max)^power * radius at
|
|
2489
|
+
# 96 dpi, radius .12 inches by default (R plt.plotly bubbles)
|
|
2490
|
+
sizes = None
|
|
2491
|
+
if bin_diam is not None: # bins sized by their counts
|
|
2492
|
+
sizes, size_name = [(bin_diam, bin_n)], "n"
|
|
2493
|
+
if size_ser is not None:
|
|
2494
|
+
sv = size_ser.to_numpy(dtype=float)
|
|
2495
|
+
rad = 0.12 if radius is None else float(radius)
|
|
2496
|
+
pw = 0.5 if power is None else float(power)
|
|
2497
|
+
sz = np.clip(sv, 0, None) ** pw
|
|
2498
|
+
mx = float(sz.max()) if len(sz) else 0.0
|
|
2499
|
+
diam = (2 * (sz / mx) * rad * 96 if mx > 0
|
|
2500
|
+
else np.zeros_like(sz))
|
|
2501
|
+
sizes = ([(diam, sv)] if by_ser is None else
|
|
2502
|
+
[(diam[bv == nm], sv[bv == nm]) for nm in by_order])
|
|
2503
|
+
if show:
|
|
2504
|
+
print("\nSome Parameter values (can be manually set)")
|
|
2505
|
+
print("-" * 55)
|
|
2506
|
+
print(f"radius: {rad:.2f} size of largest bubble")
|
|
2507
|
+
print(f"power: {pw:.2f} relative bubble sizes")
|
|
2508
|
+
|
|
2197
2509
|
# ----- jitter: the display coordinates only --------------------
|
|
2198
2510
|
# auto-jitter an axis with few discrete values (R plt.main.R
|
|
2199
2511
|
# ~634-659); the fit, ellipse, and statistics use the
|
|
@@ -2240,6 +2552,7 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
2240
2552
|
fills = ([get_option("pt_color", "#324E5C")] if n_grp == 1
|
|
2241
2553
|
else by_colors(n_grp))
|
|
2242
2554
|
else:
|
|
2555
|
+
fill = _expand_palette(fill, n_grp)
|
|
2243
2556
|
fills = list(fill) if isinstance(fill, (list, tuple)) \
|
|
2244
2557
|
else [fill]
|
|
2245
2558
|
borders = None if color is None else color
|
|
@@ -2261,6 +2574,7 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
2261
2574
|
"ts_stack requires the same dates at every "
|
|
2262
2575
|
"level of by=")
|
|
2263
2576
|
if ts_area_fill is not None:
|
|
2577
|
+
ts_area_fill = _expand_palette(ts_area_fill, n_grp)
|
|
2264
2578
|
a_base = (list(ts_area_fill)
|
|
2265
2579
|
if isinstance(ts_area_fill, (list, tuple))
|
|
2266
2580
|
else [ts_area_fill])
|
|
@@ -2287,7 +2601,7 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
2287
2601
|
cum = yc
|
|
2288
2602
|
groups = stacked
|
|
2289
2603
|
disp_groups = stacked
|
|
2290
|
-
elif is_date and ts_area_fill is not None:
|
|
2604
|
+
elif (is_date or index_x) and ts_area_fill is not None:
|
|
2291
2605
|
nm0, xg, yg = groups[0]
|
|
2292
2606
|
lo, hi = float(yg.min()), float(yg.max())
|
|
2293
2607
|
pad_a = 0.04 * (hi - lo) if hi > lo else 1.0
|
|
@@ -2315,7 +2629,12 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
2315
2629
|
fit_lines, se_polys, fit_stats = [], [], []
|
|
2316
2630
|
err_lines = [] # fit_errors residual segments
|
|
2317
2631
|
if fit != "off":
|
|
2318
|
-
|
|
2632
|
+
# a date x is fit as days, the fit drawn back on the dates
|
|
2633
|
+
day = 86400e9 # nanoseconds
|
|
2634
|
+
for nm, xg, yg in (
|
|
2635
|
+
[(n_, xg_.astype("datetime64[ns]").astype("int64") / day,
|
|
2636
|
+
yg_) for n_, xg_, yg_ in groups]
|
|
2637
|
+
if is_date else groups):
|
|
2319
2638
|
if len(xg) < 2:
|
|
2320
2639
|
continue
|
|
2321
2640
|
if fit == "loess": # R: bands for lm and loess
|
|
@@ -2325,7 +2644,7 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
2325
2644
|
if fit_errors:
|
|
2326
2645
|
err_lines.append((xs, ys_s, f))
|
|
2327
2646
|
for lv in se_levels:
|
|
2328
|
-
tq = sps.t.ppf((1 + lv) / 2,
|
|
2647
|
+
tq = sps.t.ppf((1 + lv) / 2, _loess.df)
|
|
2329
2648
|
se_polys.append((
|
|
2330
2649
|
np.concatenate([xs, xs[::-1]]),
|
|
2331
2650
|
np.concatenate([f + tq * se_f,
|
|
@@ -2340,23 +2659,36 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
2340
2659
|
err_lines.append((xs[okf], ys_s[okf], f[okf]))
|
|
2341
2660
|
if fit == "lm":
|
|
2342
2661
|
for lv in se_levels:
|
|
2343
|
-
|
|
2662
|
+
band = _se_band(xs, ys_s, f, lv)
|
|
2663
|
+
if band is not None:
|
|
2664
|
+
se_polys.append(band)
|
|
2665
|
+
|
|
2666
|
+
if is_date and fit != "off": # days back to dates
|
|
2667
|
+
def _dates(v):
|
|
2668
|
+
v = np.asarray(v, dtype=float)
|
|
2669
|
+
return (v * day).round().astype("int64").astype(
|
|
2670
|
+
"datetime64[ns]")
|
|
2671
|
+
for fl in fit_lines:
|
|
2672
|
+
fl["x"] = _dates(fl["x"])
|
|
2673
|
+
se_polys = [(_dates(bx), by_) for bx, by_ in se_polys]
|
|
2674
|
+
err_lines = [(_dates(xe), ye, fe) for xe, ye, fe in err_lines]
|
|
2344
2675
|
|
|
2345
2676
|
# ----- ellipses ------------------------------------------------
|
|
2346
2677
|
if ellipse is True:
|
|
2347
2678
|
ellipse = 0.95
|
|
2348
2679
|
ellipses = []
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2680
|
+
e_levels = _ellipse_levels(ellipse)
|
|
2681
|
+
for nm, xg, yg in (groups if e_levels else []):
|
|
2682
|
+
if len(xg) < 3:
|
|
2683
|
+
who = "" if nm is None else f" for group '{nm}'"
|
|
2684
|
+
raise ValueError(
|
|
2685
|
+
f"ellipse: need at least 3 points{who}, "
|
|
2686
|
+
f"found {len(xg)}")
|
|
2687
|
+
# level by level, each over the groups, so ellipse k belongs to
|
|
2688
|
+
# group k % n_grp
|
|
2689
|
+
for lv in e_levels:
|
|
2353
2690
|
for nm, xg, yg in groups:
|
|
2354
|
-
|
|
2355
|
-
who = "" if nm is None else f" for group '{nm}'"
|
|
2356
|
-
raise ValueError(
|
|
2357
|
-
f"ellipse: need at least 3 points{who}, "
|
|
2358
|
-
f"found {len(xg)}")
|
|
2359
|
-
ellipses.append(_ellipse_region(xg, yg, ellipse))
|
|
2691
|
+
ellipses.append(_ellipse_region(xg, yg, lv))
|
|
2360
2692
|
|
|
2361
2693
|
if digits_d is None:
|
|
2362
2694
|
digits_d = 2
|
|
@@ -2479,11 +2811,13 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
2479
2811
|
fig = plt_plotly(
|
|
2480
2812
|
disp_groups, by_name=by,
|
|
2481
2813
|
fill=fills, border=borders, shape=pt_shape, pt_size=pt_size,
|
|
2814
|
+
sizes=sizes, size_name=size_name,
|
|
2482
2815
|
x_lab=x if xlab is None else xlab,
|
|
2483
2816
|
y_lab=y if ylab is None else ylab,
|
|
2484
2817
|
ax=ax, gridT1=gridT1, gridT2=ax["axT2"],
|
|
2485
2818
|
main=main, digits_d=digits_d,
|
|
2486
|
-
connect=is_date or show_runs or index_x
|
|
2819
|
+
connect=(is_date or show_runs or index_x
|
|
2820
|
+
or bin_diam is not None), ln_width=line_width,
|
|
2487
2821
|
is_date=is_date,
|
|
2488
2822
|
pt_opacity=1 - transparency,
|
|
2489
2823
|
area_polys=area_polys,
|
|
@@ -2521,6 +2855,17 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
2521
2855
|
# runs test: connect the points and report the run analysis
|
|
2522
2856
|
# below; the run chart's center line is the median, so a run
|
|
2523
2857
|
# defaults center_line to "median" (XY.R ~809)
|
|
2858
|
+
# unspecified, a run chart shows its median unless the values
|
|
2859
|
+
# seldom cross their mean, fewer than 15% of the steps (XY.R ~2382)
|
|
2860
|
+
if center_line is None:
|
|
2861
|
+
center_line = "off"
|
|
2862
|
+
yc = np.asarray(yv, dtype=float)
|
|
2863
|
+
yc = yc[np.isfinite(yc)]
|
|
2864
|
+
if index_x and len(yc) > 1:
|
|
2865
|
+
m_c = yc.mean()
|
|
2866
|
+
n_change = int(np.sum((yc[1:] > m_c) != (yc[:-1] > m_c)))
|
|
2867
|
+
if n_change / (len(yc) - 1) >= 0.15:
|
|
2868
|
+
center_line = "median"
|
|
2524
2869
|
run_lines = None
|
|
2525
2870
|
if show_runs:
|
|
2526
2871
|
m_run, run_lines = _run_analysis(yv, digits_d, True)
|
|
@@ -2536,7 +2881,7 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
2536
2881
|
m_cl, lbl = float(np.median(yv)), " median"
|
|
2537
2882
|
else: # "zero": line but no label
|
|
2538
2883
|
m_cl, lbl = 0.0, ""
|
|
2539
|
-
gray =
|
|
2884
|
+
gray = as_plotly_color("gray50")
|
|
2540
2885
|
fig.add_hline(y=m_cl, line=dict(color=gray, dash="dash",
|
|
2541
2886
|
width=1))
|
|
2542
2887
|
if lbl:
|
|
@@ -2601,7 +2946,7 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
2601
2946
|
fig.add_trace(go.Scatter(
|
|
2602
2947
|
x=xs2, y=f2, mode="lines",
|
|
2603
2948
|
name="Fit (no outliers)",
|
|
2604
|
-
line=dict(color=
|
|
2949
|
+
line=dict(color=as_plotly_color(
|
|
2605
2950
|
get_option("fit_color", "#5C4032")
|
|
2606
2951
|
if fit_color is None else fit_color),
|
|
2607
2952
|
width=get_option("fit_lwd", 2)
|
|
@@ -2638,6 +2983,8 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
2638
2983
|
say(xc.suggest_series(pre, ".Index", y, dname, given,
|
|
2639
2984
|
ser_extra, run=True, pt_size=pt_size),
|
|
2640
2985
|
xc.run_summary(yv, dd_r), run_lines[1:])
|
|
2986
|
+
elif bin_diam is not None: # the bin tables are the report
|
|
2987
|
+
pass
|
|
2641
2988
|
else:
|
|
2642
2989
|
say(*scatter_report(groups, fit_stats, md_lines))
|
|
2643
2990
|
if fit_new is not None and fit in _FIT_NEW_OK and show:
|
|
@@ -2660,4 +3007,94 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
2660
3007
|
# font_size= scales all text of the returned figure
|
|
2661
3008
|
from .stats_out import attach_stats as _attach_stats # noqa: E402
|
|
2662
3009
|
from .style import with_theme as _with_theme # noqa: E402
|
|
2663
|
-
|
|
3010
|
+
from .utils import file_output # noqa: E402
|
|
3011
|
+
def _banking(x, y, data, filter, facet):
|
|
3012
|
+
"""lattice's aspect="xy": banking to 45 degrees, the median of
|
|
3013
|
+
|dx/dy| between x-adjacent points, each difference scaled by the
|
|
3014
|
+
range of its variable, the median over the facet panels.
|
|
3015
|
+
R analog: lattice::banking() of prepanel.default.xyplot"""
|
|
3016
|
+
d = data.query(filter) if filter is not None else data
|
|
3017
|
+
fcols = ([] if facet is None else
|
|
3018
|
+
[facet] if isinstance(facet, str) else list(facet))
|
|
3019
|
+
d = d[[x, y] + fcols].dropna()
|
|
3020
|
+
xn = d[x]
|
|
3021
|
+
if pd.api.types.is_datetime64_any_dtype(xn):
|
|
3022
|
+
xn = xn.astype("datetime64[ns]").astype("int64")
|
|
3023
|
+
xn = xn.to_numpy(dtype=float)
|
|
3024
|
+
yn = d[y].to_numpy(dtype=float)
|
|
3025
|
+
rx, ry = np.ptp(xn), np.ptp(yn)
|
|
3026
|
+
if rx == 0 or ry == 0:
|
|
3027
|
+
return 1.0
|
|
3028
|
+
keys = ([np.zeros(len(d), int)] if not fcols else
|
|
3029
|
+
[d[c].astype(str).to_numpy() for c in fcols])
|
|
3030
|
+
key = pd.Series(list(zip(*keys)))
|
|
3031
|
+
banks = []
|
|
3032
|
+
for k in key.unique():
|
|
3033
|
+
m = (key == k).to_numpy()
|
|
3034
|
+
od = np.argsort(xn[m], kind="stable")
|
|
3035
|
+
dx = np.diff(xn[m][od]) / rx
|
|
3036
|
+
dy = np.diff(yn[m][od]) / ry
|
|
3037
|
+
ok = (dx != 0) & (dy != 0)
|
|
3038
|
+
if ok.any():
|
|
3039
|
+
banks.append(float(np.median(np.abs(dx[ok] / dy[ok]))))
|
|
3040
|
+
return float(np.median(banks)) if banks else 1.0
|
|
3041
|
+
|
|
3042
|
+
|
|
3043
|
+
def _set_aspect(fig, ratio, width=700):
|
|
3044
|
+
"""Size the figure so each panel's height is ratio times its
|
|
3045
|
+
width, from the panel's share of the plotting area and the
|
|
3046
|
+
margins, at width pixels unless the figure sets its own."""
|
|
3047
|
+
lay = fig.layout
|
|
3048
|
+
W = lay.width or width
|
|
3049
|
+
m = lay.margin
|
|
3050
|
+
ml = 80 if m.l is None else m.l
|
|
3051
|
+
mr = 80 if m.r is None else m.r
|
|
3052
|
+
mt = 100 if m.t is None else m.t
|
|
3053
|
+
mb = 80 if m.b is None else m.b
|
|
3054
|
+
xd = lay.xaxis.domain or (0, 1)
|
|
3055
|
+
yd = lay.yaxis.domain or (0, 1)
|
|
3056
|
+
panel_w = (xd[1] - xd[0]) * (W - ml - mr)
|
|
3057
|
+
H = ratio * panel_w / (yd[1] - yd[0]) + mt + mb
|
|
3058
|
+
fig.update_layout(width=W, height=round(H))
|
|
3059
|
+
|
|
3060
|
+
|
|
3061
|
+
def _with_aspect(func):
|
|
3062
|
+
"""aspect= the shape of the panels as their height over width,
|
|
3063
|
+
lattice's aspect: a number, "xy" to bank the median slope to 45
|
|
3064
|
+
degrees, or "fill", the default, the panels filling the figure."""
|
|
3065
|
+
import functools
|
|
3066
|
+
import inspect
|
|
3067
|
+
sig = inspect.signature(func)
|
|
3068
|
+
|
|
3069
|
+
@functools.wraps(func)
|
|
3070
|
+
def wrapper(*args, aspect="fill", **kwargs):
|
|
3071
|
+
if not (aspect in ("fill", "xy") or (
|
|
3072
|
+
isinstance(aspect, (int, float))
|
|
3073
|
+
and not isinstance(aspect, bool) and aspect > 0)):
|
|
3074
|
+
raise ValueError(
|
|
3075
|
+
'aspect is the panel height over its width: a positive '
|
|
3076
|
+
'number, "xy" to bank\nto 45 degrees, or "fill"')
|
|
3077
|
+
fig = func(*args, **kwargs)
|
|
3078
|
+
if aspect == "fill" or not hasattr(fig, "layout"):
|
|
3079
|
+
return fig
|
|
3080
|
+
if aspect == "xy":
|
|
3081
|
+
b = sig.bind_partial(*args, **kwargs).arguments
|
|
3082
|
+
x, y = b.get("x"), b.get("y")
|
|
3083
|
+
if not (isinstance(x, str) and isinstance(y, str)
|
|
3084
|
+
and b.get("data") is not None
|
|
3085
|
+
and x in b["data"] and y in b["data"]):
|
|
3086
|
+
raise ValueError(
|
|
3087
|
+
'aspect="xy" banks the slopes of one x variable '
|
|
3088
|
+
"and one y variable of the data")
|
|
3089
|
+
ratio = _banking(x, y, b["data"], b.get("filter"),
|
|
3090
|
+
b.get("facet"))
|
|
3091
|
+
else:
|
|
3092
|
+
ratio = float(aspect)
|
|
3093
|
+
_set_aspect(fig, ratio)
|
|
3094
|
+
return fig
|
|
3095
|
+
|
|
3096
|
+
return wrapper
|
|
3097
|
+
|
|
3098
|
+
|
|
3099
|
+
XY = file_output(font_scaled(_attach_stats(_with_theme(_with_aspect(XY)),
|
|
3100
|
+
capture=True)))
|