lessPython 0.2.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 +74 -15
- lessPy/Chart.py +431 -88
- lessPy/Correlation.py +40 -11
- lessPy/Flows.py +3 -3
- lessPy/Logit.py +15 -11
- lessPy/Regression.py +27 -21
- lessPy/X.py +280 -42
- lessPy/XY.py +1332 -239
- lessPy/__init__.py +12 -2
- lessPy/_themes.py +5007 -0
- lessPy/bc_items_plotly.py +33 -20
- lessPy/bc_plotly.py +27 -10
- lessPy/bubble_plotly.py +33 -17
- lessPy/corCFA.py +9 -5
- lessPy/corProp.py +8 -4
- lessPy/corReflect.py +8 -4
- lessPy/corReorder.py +8 -4
- lessPy/corScree.py +12 -8
- lessPy/data/StockPrice.csv +1401 -1386
- lessPy/details.py +17 -4
- lessPy/dn_plotly.py +74 -54
- lessPy/dot_plotly.py +9 -8
- lessPy/freq_poly_plotly.py +18 -11
- lessPy/getColors.py +247 -20
- lessPy/hier_plotly.py +41 -14
- lessPy/hs_plotly.py +23 -11
- lessPy/pie_plotly.py +1 -1
- lessPy/pivot.py +39 -9
- lessPy/plotly_utils.py +162 -29
- lessPy/plt_add.py +2 -7
- lessPy/plt_contour.py +6 -6
- lessPy/plt_contour_facet.py +3 -3
- lessPy/plt_mat_plotly.py +85 -12
- lessPy/plt_plotly.py +34 -18
- lessPy/plt_smooth.py +4 -4
- lessPy/plt_time.py +10 -3
- lessPy/prob_norm.py +7 -3
- lessPy/prob_tcut.py +7 -3
- lessPy/prob_znorm.py +7 -1
- lessPy/profile_plotly.py +5 -4
- lessPy/radar_plotly.py +4 -3
- lessPy/rcolors.py +461 -0
- lessPy/simCImean.py +7 -3
- lessPy/simCLT.py +7 -3
- lessPy/simFlips.py +7 -3
- lessPy/simMeans.py +7 -3
- lessPy/stats_out.py +257 -11
- lessPy/style.py +167 -0
- lessPy/sunflower_plotly.py +141 -0
- lessPy/ttest.py +17 -13
- lessPy/utils.py +146 -6
- lessPy/vbs_plotly.py +39 -27
- lessPy/x_console.py +763 -0
- {lesspython-0.2.0.dist-info → lesspython-0.3.1.dist-info}/METADATA +13 -2
- lesspython-0.3.1.dist-info/RECORD +89 -0
- {lesspython-0.2.0.dist-info → lesspython-0.3.1.dist-info}/WHEEL +1 -1
- lesspython-0.2.0.dist-info/RECORD +0 -84
- {lesspython-0.2.0.dist-info → lesspython-0.3.1.dist-info}/licenses/LICENSE +0 -0
- {lesspython-0.2.0.dist-info → lesspython-0.3.1.dist-info}/top_level.txt +0 -0
lessPy/XY.py
CHANGED
|
@@ -63,20 +63,24 @@ 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 (
|
|
70
73
|
BASE_COLORS, as_plotly_color, axis_format, axis_num,
|
|
71
74
|
facet_fig, facet_panels, finish_facet,
|
|
72
75
|
get_tick_fmt, legend_style, make_trans, plot_border,
|
|
73
|
-
plotly_style, square_layout,
|
|
74
|
-
font_scaled)
|
|
76
|
+
plotly_style, square_layout, marks, to_hex, x_grid,
|
|
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,55 +195,362 @@ 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
|
+
# 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
|
|
221
|
+
X = np.column_stack([xc ** k for k in range(degree + 1)])
|
|
222
|
+
XtW = X.T * w[nz]
|
|
223
|
+
B = np.linalg.pinv(XtW @ X) @ XtW
|
|
224
|
+
val = np.zeros(n)
|
|
225
|
+
slope = np.zeros(n)
|
|
226
|
+
val[nz] = B[0]
|
|
227
|
+
slope[nz] = B[1] / sc
|
|
228
|
+
return val, slope
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _loess_kd(xs, span, cell=0.2):
|
|
232
|
+
"""Leaf cells of R's kd tree on one sorted x: the bounding box
|
|
233
|
+
widened by 0.5% each side, a cell split at its median x (moved
|
|
234
|
+
off ties) while it holds more than floor(n*span*cell) points.
|
|
235
|
+
Returns (lower, upper, split) per leaf, the split for the walk
|
|
236
|
+
down the tree. R analog: ehg126(), ehg124()"""
|
|
237
|
+
n = len(xs)
|
|
238
|
+
lo, hi = float(xs[0]), float(xs[-1])
|
|
239
|
+
mu = 0.005 * max(hi - lo, 1e-10 * max(abs(lo), abs(hi)) + 1e-30)
|
|
240
|
+
fc = int(np.floor(n * span * cell))
|
|
241
|
+
nodes = [] # (l, u, vlo, vhi), 1-based
|
|
242
|
+
|
|
243
|
+
def build(l, u, vlo, vhi):
|
|
244
|
+
leaf = (u - l) + 1 <= fc
|
|
245
|
+
if not leaf:
|
|
246
|
+
m = (l + u) // 2
|
|
247
|
+
off = 0
|
|
248
|
+
while l <= m + off < u:
|
|
249
|
+
if xs[m + off - 1] == xs[m + off]:
|
|
250
|
+
off = -off
|
|
251
|
+
if off >= 0:
|
|
252
|
+
off += 1
|
|
253
|
+
else:
|
|
254
|
+
m += off
|
|
255
|
+
break
|
|
256
|
+
v = float(xs[m - 1])
|
|
257
|
+
leaf = v == vlo or v == vhi
|
|
258
|
+
if leaf:
|
|
259
|
+
nodes.append((vlo, vhi))
|
|
260
|
+
return
|
|
261
|
+
build(l, m, vlo, v)
|
|
262
|
+
build(m + 1, u, v, vhi)
|
|
263
|
+
|
|
264
|
+
build(1, n, lo - mu, hi + mu)
|
|
265
|
+
return nodes
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _loess(xv, yv, span, degree=2, family="gaussian"):
|
|
269
|
+
"""R's default loess at the sorted x values: local fits of the
|
|
270
|
+
given degree at the vertices of a kd tree, each giving a value
|
|
271
|
+
and a slope, joined by cubic Hermite interpolation within each
|
|
272
|
+
cell (surface="interpolate"). family="symmetric" reweights by
|
|
273
|
+
the bisquare of the residuals over 4 iterations. Also the SE of
|
|
274
|
+
fit from the rows of the resulting smoother, as predict(l.ln,
|
|
275
|
+
se=TRUE). span may be given as (span, degree, family).
|
|
276
|
+
R analog: .plt.fit() loess(y.lv ~ x.lv, span, degree, family)"""
|
|
277
|
+
if isinstance(span, tuple):
|
|
278
|
+
span, degree, family = span
|
|
200
279
|
od = np.argsort(xv, kind="stable")
|
|
201
|
-
xs, ys = xv[od], yv[od]
|
|
280
|
+
xs, ys = np.asarray(xv, float)[od], np.asarray(yv, float)[od]
|
|
202
281
|
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
|
-
|
|
282
|
+
cells = _loess_kd(xs, span)
|
|
283
|
+
verts = sorted({v for c in cells for v in c})
|
|
284
|
+
rw = np.ones(n)
|
|
285
|
+
n_iter = 4 if family == "symmetric" else 1
|
|
286
|
+
for it in range(n_iter):
|
|
287
|
+
rows = {v: _loess_rows(xs, v, span, degree, rw) for v in verts}
|
|
288
|
+
L = np.empty((n, n))
|
|
289
|
+
for i, z in enumerate(xs):
|
|
290
|
+
for v0, v1 in cells: # x on a split goes left
|
|
291
|
+
if z <= v1:
|
|
292
|
+
break
|
|
293
|
+
hh = v1 - v0
|
|
294
|
+
u = (z - v0) / hh
|
|
295
|
+
g0, s0 = rows[v0]
|
|
296
|
+
g1, s1 = rows[v1]
|
|
297
|
+
L[i] = ((1 - u) ** 2 * (1 + 2 * u) * g0
|
|
298
|
+
+ u * u * (3 - 2 * u) * g1
|
|
299
|
+
+ (u * (1 - u) ** 2 * s0 - u * u * (1 - u) * s1) * hh)
|
|
300
|
+
f = L @ ys
|
|
301
|
+
res = ys - f
|
|
302
|
+
if it < n_iter - 1: # R lowesw(): bisquare weights
|
|
303
|
+
cmad = 6 * np.median(np.abs(res))
|
|
304
|
+
if cmad < np.finfo(float).tiny:
|
|
305
|
+
rw = np.ones(n)
|
|
306
|
+
else:
|
|
307
|
+
r = np.abs(res)
|
|
308
|
+
rw = np.where(r > 0.999 * cmad, 0.0,
|
|
309
|
+
np.where(r > 0.001 * cmad,
|
|
310
|
+
(1 - (r / cmad) ** 2) ** 2, 1.0))
|
|
311
|
+
l2 = (L ** 2).sum(axis=1)
|
|
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)
|
|
226
318
|
return xs, ys, f, np.sqrt(res @ res / dof * l2)
|
|
227
319
|
|
|
228
320
|
|
|
321
|
+
_QQ_LAB = {".normal": "Normal Quantiles",
|
|
322
|
+
".lognormal": "Lognormal Quantiles",
|
|
323
|
+
".exponential": "Exponential Quantiles",
|
|
324
|
+
".uniform": "Uniform Quantiles"}
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _qq_quantiles(y, key):
|
|
328
|
+
"""Theoretical quantiles, one per value of y in the row order of
|
|
329
|
+
y (NaN where y is missing): the i-th smallest y pairs with the
|
|
330
|
+
i-th smallest quantile of the distribution fit by y's moments.
|
|
331
|
+
R analog: .qq.quantiles() (qq.R)"""
|
|
332
|
+
y = np.asarray(y, dtype=float)
|
|
333
|
+
ok = ~np.isnan(y)
|
|
334
|
+
n = int(ok.sum())
|
|
335
|
+
if n < 3:
|
|
336
|
+
raise ValueError(
|
|
337
|
+
"A q-q chart requires at least 3 non-missing values of y, "
|
|
338
|
+
f"but only {n} found.")
|
|
339
|
+
yv = y[ok]
|
|
340
|
+
m, sd = yv.mean(), yv.std(ddof=1)
|
|
341
|
+
if sd == 0:
|
|
342
|
+
raise ValueError(
|
|
343
|
+
"All values of y are the same, so there is no "
|
|
344
|
+
"distribution\nto compare against a theoretical "
|
|
345
|
+
"distribution.")
|
|
346
|
+
if key == ".lognormal" and (yv <= 0).any():
|
|
347
|
+
raise ValueError(
|
|
348
|
+
"A lognormal distribution is defined only for positive "
|
|
349
|
+
f"values,\nbut y has {(yv <= 0).sum()} value(s) of zero or "
|
|
350
|
+
"less.\n\nFor data that include zero, consider .exponential")
|
|
351
|
+
if key == ".exponential" and (yv < 0).any():
|
|
352
|
+
raise ValueError(
|
|
353
|
+
"An exponential distribution is defined only for "
|
|
354
|
+
f"non-negative\nvalues, but y has {(yv < 0).sum()} "
|
|
355
|
+
"negative value(s).")
|
|
356
|
+
a = 3 / 8 if n <= 10 else 0.5 # R ppoints()
|
|
357
|
+
p = (np.arange(1, n + 1) - a) / (n + 1 - 2 * a)
|
|
358
|
+
if key == ".normal":
|
|
359
|
+
q = sps.norm.ppf(p, m, sd)
|
|
360
|
+
elif key == ".lognormal":
|
|
361
|
+
lg = np.log(yv)
|
|
362
|
+
q = np.exp(sps.norm.ppf(p, lg.mean(), lg.std(ddof=1)))
|
|
363
|
+
elif key == ".exponential":
|
|
364
|
+
q = -m * np.log1p(-p)
|
|
365
|
+
else:
|
|
366
|
+
q = m - np.sqrt(3) * sd + p * 2 * np.sqrt(3) * sd
|
|
367
|
+
out = np.full(len(y), np.nan)
|
|
368
|
+
rk = np.empty(n, dtype=int) # rank, ties.method="first"
|
|
369
|
+
rk[np.argsort(yv, kind="stable")] = np.arange(n)
|
|
370
|
+
out[ok] = q[rk]
|
|
371
|
+
return out
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _qq_reference(fig, fit_color, fit_lwd):
|
|
375
|
+
"""The 45-degree reference of a q-q chart in every panel, from
|
|
376
|
+
the range of that panel's plotted values, drawn beneath the
|
|
377
|
+
points. R analog: plt.plotly.R qq reference"""
|
|
378
|
+
panels = {}
|
|
379
|
+
for tr in fig.data:
|
|
380
|
+
if tr.type != "scatter" or tr.x is None or tr.y is None:
|
|
381
|
+
continue
|
|
382
|
+
key = (tr.xaxis or "x", tr.yaxis or "y")
|
|
383
|
+
v = np.r_[np.asarray(tr.x, float), np.asarray(tr.y, float)]
|
|
384
|
+
v = v[np.isfinite(v)]
|
|
385
|
+
if len(v):
|
|
386
|
+
lo, hi = panels.get(key, (np.inf, -np.inf))
|
|
387
|
+
panels[key] = (min(lo, v.min()), max(hi, v.max()))
|
|
388
|
+
refs = [go.Scatter(x=[lo, hi], y=[lo, hi], mode="lines",
|
|
389
|
+
xaxis=xa, yaxis=ya,
|
|
390
|
+
line=dict(color=as_plotly_color(get_option(
|
|
391
|
+
"fit_color", "#5C4032")
|
|
392
|
+
if fit_color is None else fit_color),
|
|
393
|
+
width=1.5 if fit_lwd is None else fit_lwd),
|
|
394
|
+
showlegend=False, hoverinfo="skip")
|
|
395
|
+
for (xa, ya), (lo, hi) in panels.items()]
|
|
396
|
+
k = len(refs)
|
|
397
|
+
fig.add_traces(refs)
|
|
398
|
+
fig.data = fig.data[-k:] + fig.data[:-k] if k else fig.data
|
|
399
|
+
return fig
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _ts_ylab(y_name, unit, aggregated, ts_agg):
|
|
403
|
+
"""Default value-axis label of a time series: the aggregation
|
|
404
|
+
and the time unit, as "Total Sales by Year"; a vector of y
|
|
405
|
+
(y_name None) is named by its legend, so "Total by Year", and
|
|
406
|
+
with no aggregation to report either, no label. R analog: the
|
|
407
|
+
y.lab of .plt.main() for a date x"""
|
|
408
|
+
if unit in (None, "unknown"):
|
|
409
|
+
return y_name or ""
|
|
410
|
+
tu = "days" if unit == "days7" else unit
|
|
411
|
+
tu = tu[:-1] if tu.endswith("s") else tu
|
|
412
|
+
agg = (("Total" if ts_agg == "sum" else "Mean")
|
|
413
|
+
if aggregated else "")
|
|
414
|
+
if y_name is None and not agg:
|
|
415
|
+
return ""
|
|
416
|
+
return " ".join(p for p in (agg, y_name, "by", tu.capitalize())
|
|
417
|
+
if p)
|
|
418
|
+
|
|
419
|
+
|
|
229
420
|
def _se_band(xs, ys, f, level):
|
|
230
421
|
"""SE band polygon about a least-squares line: fit +/- t * SE
|
|
231
422
|
of the conditional mean. R analog: plt.main.R se bands, from
|
|
232
|
-
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."""
|
|
233
426
|
n = len(xs)
|
|
427
|
+
if n < 3 or np.ptp(xs) == 0:
|
|
428
|
+
return None
|
|
234
429
|
mse = ((ys - f) ** 2).sum() / (n - 2)
|
|
235
430
|
xbar = xs.mean()
|
|
236
431
|
sxx = ((xs - xbar) ** 2).sum()
|
|
237
432
|
se = np.sqrt(mse * (1 / n + (xs - xbar) ** 2 / sxx))
|
|
238
|
-
tq = sps.t.ppf((1 + level) / 2, n -
|
|
433
|
+
tq = sps.t.ppf((1 + level) / 2, n - 2) # the residual df
|
|
239
434
|
return (np.concatenate([xs, xs[::-1]]),
|
|
240
435
|
np.concatenate([f + tq * se, (f - tq * se)[::-1]]))
|
|
241
436
|
|
|
242
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
|
+
|
|
243
554
|
def _ellipse_region(xv, yv, level, npoints=100):
|
|
244
555
|
"""Bivariate-normal data ellipse, the Murdoch and Chow
|
|
245
556
|
construction of the ellipse package that XY() cites.
|
|
@@ -260,7 +571,7 @@ def _xy_facet(xv, yv, by_arr, by_order, facet_arr, facet_order,
|
|
|
260
571
|
ellipse, ellipse_fill, ellipse_color, ellipse_lwd,
|
|
261
572
|
x_lab, y_lab, main, digits_d,
|
|
262
573
|
facet2_arr=None, facet2_order=None,
|
|
263
|
-
facet2_name=None, n_col=1):
|
|
574
|
+
facet2_name=None, n_col=1, fit_errors=False):
|
|
264
575
|
"""One scatter panel per facet level on shared axes,
|
|
265
576
|
following the faceted-histogram conventions (_hs_facet):
|
|
266
577
|
first level on the top panel, strip labels, legend
|
|
@@ -291,9 +602,10 @@ def _xy_facet(xv, yv, by_arr, by_order, facet_arr, facet_order,
|
|
|
291
602
|
if fit == "loess":
|
|
292
603
|
xs, ys_s, f, se_f = _loess(xg, yg, span)
|
|
293
604
|
fit_lines.append({"name": gname, "x": xs,
|
|
294
|
-
"y": f, "g": g
|
|
605
|
+
"y": f, "g": g,
|
|
606
|
+
"err": (xs, ys_s, f)})
|
|
295
607
|
for lv in se_levels:
|
|
296
|
-
tq = sps.t.ppf((1 + lv) / 2,
|
|
608
|
+
tq = sps.t.ppf((1 + lv) / 2, _loess.df)
|
|
297
609
|
se_polys.append((
|
|
298
610
|
np.concatenate([xs, xs[::-1]]),
|
|
299
611
|
np.concatenate(
|
|
@@ -303,14 +615,17 @@ def _xy_facet(xv, yv, by_arr, by_order, facet_arr, facet_order,
|
|
|
303
615
|
xs, ys_s, f = _plt_fit(xg, yg, fit, fit_power)
|
|
304
616
|
okf = np.isfinite(f)
|
|
305
617
|
fit_lines.append({"name": gname, "x": xs[okf],
|
|
306
|
-
"y": f[okf], "g": g
|
|
618
|
+
"y": f[okf], "g": g,
|
|
619
|
+
"err": (xs[okf], ys_s[okf],
|
|
620
|
+
f[okf])})
|
|
307
621
|
if fit == "lm":
|
|
308
622
|
for lv in se_levels:
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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)))
|
|
314
629
|
panels.append((pts, fit_lines, se_polys, ellipses))
|
|
315
630
|
for fl in fit_lines:
|
|
316
631
|
xs_all.append(fl["x"]); ys_all.append(fl["y"])
|
|
@@ -335,22 +650,22 @@ def _xy_facet(xv, yv, by_arr, by_order, facet_arr, facet_order,
|
|
|
335
650
|
row, col = pos[i]
|
|
336
651
|
for g, (gname, xg, yg) in enumerate(pts):
|
|
337
652
|
fig.add_trace(go.Scatter(
|
|
338
|
-
x=xg, y=yg,
|
|
653
|
+
x=xg, y=yg,
|
|
339
654
|
name=None if gname is None else str(gname),
|
|
340
655
|
legendgroup=(None if gname is None
|
|
341
656
|
else str(gname)),
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
sizemode="diameter",
|
|
657
|
+
**marks(shape, g, "markers", dict(
|
|
658
|
+
size=px, sizemode="diameter",
|
|
345
659
|
color=make_trans(fills[g], pt_opacity),
|
|
346
660
|
opacity=1,
|
|
347
|
-
line=dict(color=
|
|
661
|
+
line=dict(color=as_plotly_color(fills[g]),
|
|
662
|
+
width=1))),
|
|
348
663
|
hoverinfo="x+y" + ("+name" if has_groups else ""),
|
|
349
664
|
showlegend=has_groups and i == 0,
|
|
350
665
|
), row=row, col=col)
|
|
351
666
|
for g, ex, ey in ellipses:
|
|
352
|
-
edge = (
|
|
353
|
-
else
|
|
667
|
+
edge = (as_plotly_color(fills[g]) if has_groups
|
|
668
|
+
else as_plotly_color(ellipse_color))
|
|
354
669
|
fig.add_trace(go.Scatter(
|
|
355
670
|
x=ex, y=ey, mode="lines",
|
|
356
671
|
line=dict(color=edge, width=ellipse_lwd),
|
|
@@ -364,6 +679,18 @@ def _xy_facet(xv, yv, by_arr, by_order, facet_arr, facet_order,
|
|
|
364
679
|
fillcolor=as_plotly_color(se_fill),
|
|
365
680
|
hoverinfo="skip", showlegend=False,
|
|
366
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)
|
|
367
694
|
for fl in fit_lines:
|
|
368
695
|
if len(fl["x"]) < 2:
|
|
369
696
|
continue
|
|
@@ -373,8 +700,8 @@ def _xy_facet(xv, yv, by_arr, by_order, facet_arr, facet_order,
|
|
|
373
700
|
name="Fit" if single else f"Fit: {fl['name']}",
|
|
374
701
|
legendgroup=("fit" if single
|
|
375
702
|
else str(fl["name"])),
|
|
376
|
-
line=dict(color=(
|
|
377
|
-
else
|
|
703
|
+
line=dict(color=(as_plotly_color(fit_color) if single
|
|
704
|
+
else as_plotly_color(fills[fl["g"]])),
|
|
378
705
|
width=fit_lwd),
|
|
379
706
|
hoverinfo="skip",
|
|
380
707
|
showlegend=i == 0,
|
|
@@ -432,7 +759,7 @@ def _series_overlay(V, names, shared, on, fills, shape, pt_size,
|
|
|
432
759
|
xs, ys_s, f, se_f = _loess(xg, yg, span)
|
|
433
760
|
fit_lines.append((xs, f))
|
|
434
761
|
for lv in se_levels:
|
|
435
|
-
tq = sps.t.ppf((1 + lv) / 2,
|
|
762
|
+
tq = sps.t.ppf((1 + lv) / 2, _loess.df)
|
|
436
763
|
se_polys.append((
|
|
437
764
|
np.concatenate([xs, xs[::-1]]),
|
|
438
765
|
np.concatenate([f + tq * se_f,
|
|
@@ -443,9 +770,12 @@ def _series_overlay(V, names, shared, on, fills, shape, pt_size,
|
|
|
443
770
|
fit_lines.append((xs[okf], f[okf]))
|
|
444
771
|
if fit == "lm":
|
|
445
772
|
for lv in se_levels:
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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))
|
|
449
779
|
art.append((xg, yg, fit_lines, se_polys, ellipses))
|
|
450
780
|
xs_all += [xg] + [fl[0] for fl in fit_lines] \
|
|
451
781
|
+ [ex for ex, _ in ellipses]
|
|
@@ -467,7 +797,7 @@ def _series_overlay(V, names, shared, on, fills, shape, pt_size,
|
|
|
467
797
|
for ex, ey in ellipses:
|
|
468
798
|
fig.add_trace(go.Scatter(
|
|
469
799
|
x=ex, y=ey, mode="lines",
|
|
470
|
-
line=dict(color=
|
|
800
|
+
line=dict(color=as_plotly_color(fills[g]),
|
|
471
801
|
width=ellipse_lwd),
|
|
472
802
|
fill="toself",
|
|
473
803
|
fillcolor=as_plotly_color(ellipse_fill),
|
|
@@ -478,14 +808,14 @@ def _series_overlay(V, names, shared, on, fills, shape, pt_size,
|
|
|
478
808
|
fillcolor=as_plotly_color(se_fill),
|
|
479
809
|
hoverinfo="skip", showlegend=False))
|
|
480
810
|
fig.add_trace(go.Scatter(
|
|
481
|
-
x=xg, y=yg,
|
|
811
|
+
x=xg, y=yg, name=str(nm),
|
|
482
812
|
legendgroup=str(nm),
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
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))),
|
|
489
819
|
hoverinfo="x+y+name", showlegend=True))
|
|
490
820
|
for fx_l, fy_l in fit_lines:
|
|
491
821
|
if len(fx_l) < 2:
|
|
@@ -493,7 +823,7 @@ def _series_overlay(V, names, shared, on, fills, shape, pt_size,
|
|
|
493
823
|
fig.add_trace(go.Scatter(
|
|
494
824
|
x=fx_l, y=fy_l, mode="lines",
|
|
495
825
|
legendgroup=str(nm),
|
|
496
|
-
line=dict(color=
|
|
826
|
+
line=dict(color=as_plotly_color(fills[g]),
|
|
497
827
|
width=fit_lwd),
|
|
498
828
|
hoverinfo="skip", showlegend=False))
|
|
499
829
|
|
|
@@ -502,14 +832,14 @@ def _series_overlay(V, names, shared, on, fills, shape, pt_size,
|
|
|
502
832
|
pad = 0.04 * (axT2[-1] - axT2[0])
|
|
503
833
|
ax_y["range"] = [axT2[0] - pad, axT2[-1] + pad]
|
|
504
834
|
ax_y.update(showgrid=True,
|
|
505
|
-
gridcolor=
|
|
835
|
+
gridcolor=as_plotly_color(style_opts["grid_col"]),
|
|
506
836
|
gridwidth=1, griddash="dot")
|
|
507
837
|
fig.update_layout(
|
|
508
838
|
xaxis=ax_x, yaxis=ax_y,
|
|
509
839
|
shapes=x_grid(axT1) + plot_border(),
|
|
510
840
|
template=None,
|
|
511
|
-
plot_bgcolor=
|
|
512
|
-
paper_bgcolor=
|
|
841
|
+
plot_bgcolor=as_plotly_color(style_opts["panel_fill"]),
|
|
842
|
+
paper_bgcolor=as_plotly_color(style_opts["window_fill"]),
|
|
513
843
|
legend=legend_style("", style_opts),
|
|
514
844
|
**square_layout(main=bool(main)))
|
|
515
845
|
if main:
|
|
@@ -588,7 +918,7 @@ def _facet_series(V, names, shared, on, facet_arr, facet_order,
|
|
|
588
918
|
color=make_trans(fills[g],
|
|
589
919
|
pt_opacity),
|
|
590
920
|
opacity=1,
|
|
591
|
-
line=dict(color=
|
|
921
|
+
line=dict(color=as_plotly_color(fills[g]),
|
|
592
922
|
width=1)),
|
|
593
923
|
hoverinfo="x+y+name",
|
|
594
924
|
showlegend=i == 0,
|
|
@@ -599,7 +929,7 @@ def _facet_series(V, names, shared, on, facet_arr, facet_order,
|
|
|
599
929
|
fig.add_trace(go.Scatter(
|
|
600
930
|
x=fx_l, y=fy_l, mode="lines",
|
|
601
931
|
legendgroup=str(names[g]),
|
|
602
|
-
line=dict(color=
|
|
932
|
+
line=dict(color=as_plotly_color(fills[g]),
|
|
603
933
|
width=fit_lwd),
|
|
604
934
|
hoverinfo="skip", showlegend=False,
|
|
605
935
|
), row=row, col=col)
|
|
@@ -678,13 +1008,13 @@ def _xy_series(x, y, data, x_multi, by, facet, form, n_row,
|
|
|
678
1008
|
if ylab is None else ylab)
|
|
679
1009
|
|
|
680
1010
|
k = len(names)
|
|
1011
|
+
fill = _expand_palette(fill, k)
|
|
681
1012
|
if isinstance(fill, (list, tuple)):
|
|
682
1013
|
fills = [fill[i % len(fill)] for i in range(k)]
|
|
683
1014
|
elif fill is not None:
|
|
684
1015
|
fills = [fill] * k
|
|
685
1016
|
else:
|
|
686
|
-
fills =
|
|
687
|
-
for i in range(k)]
|
|
1017
|
+
fills = by_colors(k) # theme's palette, as R
|
|
688
1018
|
if transparency is None:
|
|
689
1019
|
transparency = get_option("trans_pt_fill", 0.10)
|
|
690
1020
|
if ellipse is True:
|
|
@@ -736,13 +1066,19 @@ def _ts_facet(xv, yv, facet_arr, facet_order, fill0, border0,
|
|
|
736
1066
|
pt_size, pt_opacity, x_lab, y_lab, main, digits_d,
|
|
737
1067
|
area_fill=None, area_split=0,
|
|
738
1068
|
facet_name=None, facet2_arr=None,
|
|
739
|
-
facet2_order=None, facet2_name=None, n_col=1
|
|
1069
|
+
facet2_order=None, facet2_name=None, n_col=1,
|
|
1070
|
+
by_arr=None, by_order=None, by_name=None,
|
|
1071
|
+
line_width=1.5, frcsts=None, ts_PI=0.95):
|
|
740
1072
|
"""One time-series panel per facet level on shared axes,
|
|
741
1073
|
following the faceted-scatter conventions (_xy_facet):
|
|
742
1074
|
first level on the top panel, strip labels. The x axis
|
|
743
1075
|
uses plotly's native date ticks, as the single-panel time
|
|
744
1076
|
series does. R analog: the lattice cont_cont path for a
|
|
745
|
-
date x. facet2: the two-facet grid, rows = facet2 levels.
|
|
1077
|
+
date x. facet2: the two-facet grid, rows = facet2 levels.
|
|
1078
|
+
by: several series per panel in the legend's colors, unfilled
|
|
1079
|
+
unless area_fill is asked for. frcsts: a forecast per panel
|
|
1080
|
+
(panel index -> plt_forecast result), every panel on one scale
|
|
1081
|
+
that holds the prediction intervals."""
|
|
746
1082
|
labels, pos, sel, n_row_g, n_col = facet_panels(
|
|
747
1083
|
facet_arr, facet_order, facet2_arr, facet2_order,
|
|
748
1084
|
facet_name, facet2_name, n_col)
|
|
@@ -752,15 +1088,22 @@ def _ts_facet(xv, yv, facet_arr, facet_order, fill0, border0,
|
|
|
752
1088
|
px = 5
|
|
753
1089
|
mode_pts = "lines+markers" if px > 0 else "lines"
|
|
754
1090
|
|
|
755
|
-
|
|
1091
|
+
y_all = [np.asarray(yv, float)]
|
|
1092
|
+
for fr in (frcsts or {}).values():
|
|
1093
|
+
y_all += [np.asarray(fr["y_lwr"], float),
|
|
1094
|
+
np.asarray(fr["y_upr"], float)]
|
|
1095
|
+
y_all = np.concatenate(y_all)
|
|
1096
|
+
axT2 = pretty(float(np.nanmin(y_all)), float(np.nanmax(y_all)))
|
|
756
1097
|
fmt2 = get_tick_fmt(axT2, digits_d)
|
|
757
1098
|
ax = {"axT1": None, "axL1": None,
|
|
758
1099
|
"axT2": axT2,
|
|
759
1100
|
"axL2": axis_format(axT2, digits_d)}
|
|
760
1101
|
|
|
761
1102
|
ypart = f"%{{y:{fmt2}}}" if fmt2 else "%{y}"
|
|
762
|
-
|
|
763
|
-
|
|
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>"
|
|
764
1107
|
|
|
765
1108
|
style_opts = plotly_style()
|
|
766
1109
|
fig = facet_fig(n_row_g, n_col)
|
|
@@ -768,39 +1111,66 @@ def _ts_facet(xv, yv, facet_arr, facet_order, fill0, border0,
|
|
|
768
1111
|
rng_pad = 0.04 * (axT2[-1] - axT2[0]) # panel-clipped
|
|
769
1112
|
base = min(max(float(area_split), axT2[0] - rng_pad),
|
|
770
1113
|
axT2[-1] + rng_pad)
|
|
1114
|
+
if by_arr is None:
|
|
1115
|
+
series = [(None, np.ones(len(xv), dtype=bool), fill0,
|
|
1116
|
+
border0)]
|
|
1117
|
+
else:
|
|
1118
|
+
by_s = np.asarray(by_arr).astype(str)
|
|
1119
|
+
cols = by_colors(len(by_order))
|
|
1120
|
+
series = [(str(lv), by_s == str(lv), cols[g], cols[g])
|
|
1121
|
+
for g, lv in enumerate(by_order)]
|
|
771
1122
|
for i in range(n_f):
|
|
772
|
-
m = sel[i]
|
|
773
1123
|
row, col = pos[i]
|
|
774
|
-
|
|
1124
|
+
for nm, bm, f_c, b_c in series:
|
|
1125
|
+
m = sel[i] & bm
|
|
1126
|
+
if not m.any():
|
|
1127
|
+
continue
|
|
1128
|
+
od = np.argsort(xv[m], kind="stable")
|
|
1129
|
+
xm, ym = xv[m][od], yv[m][od]
|
|
1130
|
+
if area_fill is not None:
|
|
1131
|
+
fig.add_trace(go.Scatter(
|
|
1132
|
+
x=np.concatenate([xm, xm[-1:], xm[:1]]),
|
|
1133
|
+
y=np.concatenate([ym, [base, base]]),
|
|
1134
|
+
mode="none", fill="toself",
|
|
1135
|
+
fillcolor=area_fill, hoverinfo="skip",
|
|
1136
|
+
showlegend=False,
|
|
1137
|
+
), row=row, col=col)
|
|
1138
|
+
hv = hover if nm is None else (
|
|
1139
|
+
f"{by_name}: {nm}<br>" + hover)
|
|
775
1140
|
fig.add_trace(go.Scatter(
|
|
776
|
-
x=
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
1141
|
+
x=xm, y=ym, mode=mode_pts if line_width > 0
|
|
1142
|
+
else "markers",
|
|
1143
|
+
marker=(dict(symbol="circle", size=px,
|
|
1144
|
+
sizemode="diameter",
|
|
1145
|
+
color=make_trans(f_c, pt_opacity),
|
|
1146
|
+
opacity=1,
|
|
1147
|
+
line=dict(color=as_plotly_color(b_c), width=1))
|
|
1148
|
+
if "markers" in mode_pts or line_width <= 0
|
|
1149
|
+
else None),
|
|
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),
|
|
1154
|
+
name=nm, legendgroup=nm,
|
|
1155
|
+
hovertemplate=hv,
|
|
1156
|
+
showlegend=nm is not None and i == 0,
|
|
1157
|
+
), row=row, col=col) # first level top-left
|
|
1158
|
+
if frcsts and i in frcsts:
|
|
1159
|
+
_forecast_traces(fig, frcsts[i], ts_PI, row=row, col=col,
|
|
1160
|
+
legend=i == min(frcsts))
|
|
795
1161
|
|
|
796
1162
|
finish_facet(fig, labels, ax, x_lab, y_lab,
|
|
797
1163
|
gridT1=None, style_opts=style_opts,
|
|
798
1164
|
n_col=n_col, pos=pos)
|
|
1165
|
+
if by_arr is not None or frcsts:
|
|
1166
|
+
fig.update_layout(showlegend=True,
|
|
1167
|
+
legend=legend_style(by_name, style_opts)
|
|
1168
|
+
if by_arr is not None else None)
|
|
799
1169
|
# ts panels: y spans the data; native date grid on x
|
|
800
1170
|
pad = 0.04 * (axT2[-1] - axT2[0])
|
|
801
1171
|
fig.update_yaxes(range=[axT2[0] - pad, axT2[-1] + pad])
|
|
802
1172
|
fig.update_xaxes(showgrid=True,
|
|
803
|
-
gridcolor=
|
|
1173
|
+
gridcolor=as_plotly_color(style_opts["grid_col"]),
|
|
804
1174
|
gridwidth=1)
|
|
805
1175
|
if main:
|
|
806
1176
|
title_size = round(16 * get_option("main_size", 1))
|
|
@@ -810,6 +1180,16 @@ def _ts_facet(xv, yv, facet_arr, facet_order, fill0, border0,
|
|
|
810
1180
|
return fig
|
|
811
1181
|
|
|
812
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
|
+
|
|
813
1193
|
def _area_color(col, opacity):
|
|
814
1194
|
"""Area fill color: "on" is the violin_fill option; an
|
|
815
1195
|
explicit #RRGGBBAA keeps its own alpha; otherwise the point
|
|
@@ -880,11 +1260,14 @@ def _run_analysis(yv, digits_d, show_detail):
|
|
|
880
1260
|
lines.append(f"\nTotal number of runs: {n_runs}")
|
|
881
1261
|
lines.append("Total number of values that do not equal the "
|
|
882
1262
|
f"median: {n - len(eq)}")
|
|
883
|
-
if eq:
|
|
1263
|
+
if eq: # listed only with show_runs
|
|
884
1264
|
if show_detail:
|
|
885
1265
|
lines.append("\nValues ignored that equal the median")
|
|
886
1266
|
lines += [f" #{j} {y[j - 1]:.{dd}f}" for j in eq]
|
|
887
|
-
|
|
1267
|
+
lines.append(f"Total number of values ignored: {len(eq)}")
|
|
1268
|
+
else:
|
|
1269
|
+
lines.append("Total number of values ignored that equal the "
|
|
1270
|
+
"median: 0")
|
|
888
1271
|
return m, lines
|
|
889
1272
|
|
|
890
1273
|
|
|
@@ -903,18 +1286,19 @@ def _outlier_traces(fig, xv, yv, out_idx, labels, fill0,
|
|
|
903
1286
|
sizemode="diameter",
|
|
904
1287
|
color=make_trans(fill0, pt_opacity),
|
|
905
1288
|
opacity=1,
|
|
906
|
-
line=dict(color=
|
|
1289
|
+
line=dict(color=as_plotly_color(fill0), width=1.5)),
|
|
907
1290
|
hoverinfo="x+y", showlegend=False))
|
|
908
1291
|
font_sz = max(9, round(float(ID_size) * 14))
|
|
909
1292
|
for i in out_idx:
|
|
910
1293
|
fig.add_annotation(
|
|
911
1294
|
x=xv[i], y=yv[i], text=str(labels[i]),
|
|
912
1295
|
showarrow=False, yshift=-12,
|
|
913
|
-
font=dict(color=
|
|
1296
|
+
font=dict(color=as_plotly_color(ID_color), size=font_sz,
|
|
914
1297
|
family="Arial"))
|
|
915
1298
|
|
|
916
1299
|
|
|
917
|
-
def _forecast_traces(fig, frcst, ts_PI
|
|
1300
|
+
def _forecast_traces(fig, frcst, ts_PI, row=None, col=None,
|
|
1301
|
+
legend=True):
|
|
918
1302
|
"""Overlay the forecast on the time series display: model-fit
|
|
919
1303
|
line, PI band with dotted boundaries, forecast line + points.
|
|
920
1304
|
R analog: plt.plotly.R forecast section (~lines 343-451),
|
|
@@ -925,6 +1309,16 @@ def _forecast_traces(fig, frcst, ts_PI):
|
|
|
925
1309
|
xf = np.asarray(frcst["x_fit"]) # numpy datetimes: plain
|
|
926
1310
|
xh = np.asarray(frcst["x_hat"]) # Timestamps break kaleido
|
|
927
1311
|
yf, yh = frcst["y_fit"], frcst["y_hat"]
|
|
1312
|
+
at = {} if row is None else dict(row=row, col=col)
|
|
1313
|
+
_add = fig.add_trace
|
|
1314
|
+
|
|
1315
|
+
def add(tr, **kw): # one legend entry per role
|
|
1316
|
+
if not legend:
|
|
1317
|
+
tr.showlegend = False
|
|
1318
|
+
tr.legendgroup = tr.name
|
|
1319
|
+
_add(tr, **at)
|
|
1320
|
+
|
|
1321
|
+
fig = _Adder(fig, add)
|
|
928
1322
|
fig.add_trace(go.Scatter(
|
|
929
1323
|
x=xf, y=yf, mode="lines",
|
|
930
1324
|
line={"color": fit_rgba, "width": 1.5},
|
|
@@ -953,6 +1347,12 @@ def _forecast_traces(fig, frcst, ts_PI):
|
|
|
953
1347
|
name="Forecast", showlegend=True))
|
|
954
1348
|
|
|
955
1349
|
|
|
1350
|
+
class _Adder:
|
|
1351
|
+
"""fig.add_trace routed through a placement function."""
|
|
1352
|
+
def __init__(self, fig, add):
|
|
1353
|
+
self._fig, self.add_trace = fig, add
|
|
1354
|
+
|
|
1355
|
+
|
|
956
1356
|
def _ts_fitted_lines(frcst, yv, x_name, y_name, ts_unit, digits_d):
|
|
957
1357
|
"""Table of the observed y beside the model-fitted y over the
|
|
958
1358
|
historical dates, printed when ts_fitted=True. R analog: the
|
|
@@ -980,22 +1380,24 @@ def _ts_fitted_lines(frcst, yv, x_name, y_name, ts_unit, digits_d):
|
|
|
980
1380
|
def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
981
1381
|
n_row=None, n_col=None,
|
|
982
1382
|
form="scatter", stat=None, sort="0", show_runs=False,
|
|
983
|
-
center_line=
|
|
1383
|
+
center_line=None,
|
|
984
1384
|
contour_n=None, contour_nbins=None, contour_points=None,
|
|
985
1385
|
contour_legend=None,
|
|
986
1386
|
smooth_points=100, smooth_size=1, smooth_power=0.25,
|
|
987
|
-
smooth_bins=128,
|
|
1387
|
+
smooth_bins=128, n_bins=None,
|
|
988
1388
|
enhance=False,
|
|
989
1389
|
segments_x=None, segments_y=None,
|
|
990
1390
|
add=None, x1=None, y1=None, x2=None, y2=None,
|
|
991
1391
|
fill=None, color=None, transparency=None,
|
|
992
1392
|
pt_size=1, pt_shape="circle", line_width=1.5,
|
|
1393
|
+
radius=None, power=None,
|
|
993
1394
|
jitter_x=None, jitter_y=None,
|
|
994
1395
|
MD_cut=0, out_cut=0, out_shape="circle-open", out_size=1,
|
|
995
1396
|
ID=None, ID_color="gray50", ID_size=0.6,
|
|
996
1397
|
fit="off", fit_power=1, fit_se=None, fit_errors=False,
|
|
997
1398
|
fit_new=None,
|
|
998
|
-
fit_color=None, fit_lwd=None,
|
|
1399
|
+
fit_color=None, fit_lwd=None, fit_span_loess=0.75,
|
|
1400
|
+
fit_degree_loess=2, fit_family_loess="gaussian",
|
|
999
1401
|
ellipse=0, ellipse_fill=None, ellipse_color=None,
|
|
1000
1402
|
ellipse_lwd=None,
|
|
1001
1403
|
ts_unit=None, ts_agg="sum", ts_stack=False,
|
|
@@ -1010,7 +1412,7 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
1010
1412
|
rotate_x=0, rotate_y=0, scale_x=None, scale_y=None,
|
|
1011
1413
|
xlab=None, ylab=None, main=None, digits_d=None,
|
|
1012
1414
|
quiet=None,
|
|
1013
|
-
plot_errors=None):
|
|
1415
|
+
plot_errors=None, span=None):
|
|
1014
1416
|
"""Analytic view of the relationship between two numerical
|
|
1015
1417
|
variables, optionally grouped (by=). A date x displays as a
|
|
1016
1418
|
time series. Variables are strings naming columns of the
|
|
@@ -1034,14 +1436,54 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
1034
1436
|
(contour_n, contour_nbins, contour_points,
|
|
1035
1437
|
contour_legend)):
|
|
1036
1438
|
form = "contour"
|
|
1439
|
+
contour_n_user = contour_n # a matrix cell defaults to 8
|
|
1037
1440
|
contour_n = 20 if contour_n is None else contour_n
|
|
1038
1441
|
contour_nbins = 50 if contour_nbins is None else contour_nbins
|
|
1039
1442
|
contour_points = bool(contour_points)
|
|
1040
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")
|
|
1448
|
+
if pt_shape == "sunflower": # its earlier route, kept
|
|
1449
|
+
form, pt_shape = "sunflower", "circle"
|
|
1450
|
+
# the form is consulted before by is resolved, so a vector of by
|
|
1451
|
+
# gets the sunflower's own reason (XY.R)
|
|
1452
|
+
if form == "sunflower" and by is not None:
|
|
1453
|
+
raise ValueError(
|
|
1454
|
+
'Parameter by is not available for form="sunflower".\n\n'
|
|
1455
|
+
"A petal count is defined for one set of coordinates, not\n"
|
|
1456
|
+
"separately within each group, so overlaid groups would\n"
|
|
1457
|
+
"interleave their petals at a shared coordinate.\n"
|
|
1458
|
+
"To stratify the same two variables, use facet, which "
|
|
1459
|
+
"draws\neach group in its own panel with its own "
|
|
1460
|
+
"coordinates.")
|
|
1461
|
+
if form == "sunflower" and (isinstance(x, (list, tuple))
|
|
1462
|
+
or isinstance(y, (list, tuple))):
|
|
1463
|
+
raise ValueError(
|
|
1464
|
+
"The petal count is defined for one set of coordinates, "
|
|
1465
|
+
"not\nseparately for each series, so a vector of x or y "
|
|
1466
|
+
'variables\nis not available for form="sunflower". To '
|
|
1467
|
+
'overlay the series,\nuse form="scatter".')
|
|
1468
|
+
if isinstance(by, (list, tuple)):
|
|
1469
|
+
raise ValueError(
|
|
1470
|
+
"Only one by variable is permitted, but more than one "
|
|
1471
|
+
f"specified:\n by = {list(by)}\n\n"
|
|
1472
|
+
"The groups of by are overlaid within the panel, "
|
|
1473
|
+
"distinguished by\ncolor and optionally symbol. That one "
|
|
1474
|
+
"channel is carried by the\nfirst variable, so a second "
|
|
1475
|
+
"has no encoding left by which to\nseparate its levels.\n"
|
|
1476
|
+
"To stratify by a second variable, use facet, which draws "
|
|
1477
|
+
"each of\nits groups in a panel of its own.")
|
|
1041
1478
|
if form != "scatter" and by is not None:
|
|
1042
1479
|
raise ValueError(
|
|
1043
|
-
'by
|
|
1044
|
-
|
|
1480
|
+
f'Parameter by is not available for form="{form}".\n\n'
|
|
1481
|
+
f"A {form} plot summarizes the joint density over the "
|
|
1482
|
+
"whole\nplotting region, so summaries superimposed on it "
|
|
1483
|
+
"would obstruct\none another rather than remain "
|
|
1484
|
+
"distinguishable.\nTo stratify the same two variables, "
|
|
1485
|
+
"use facet, which draws\neach group in its own panel with "
|
|
1486
|
+
"its own density.")
|
|
1045
1487
|
if add is not None and form in ("contour", "smooth"):
|
|
1046
1488
|
raise ValueError(
|
|
1047
1489
|
'add is not active for "contour" or "smooth" plots')
|
|
@@ -1051,16 +1493,30 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
1051
1493
|
"no facet=")
|
|
1052
1494
|
if axis_fmt not in ("K", ",", ".", ""):
|
|
1053
1495
|
raise ValueError('axis_fmt: "K", ",", ".", or ""')
|
|
1054
|
-
if center_line not in ("off", "mean", "median", "zero"):
|
|
1496
|
+
if center_line not in (None, "off", "mean", "median", "zero"):
|
|
1055
1497
|
raise ValueError(
|
|
1056
1498
|
'center_line: "off", "mean", "median", or "zero"')
|
|
1499
|
+
if span is not None:
|
|
1500
|
+
raise ValueError(
|
|
1501
|
+
"Parameter span has been renamed to fit_span_loess.\n\n"
|
|
1502
|
+
"It sets the span of a loess fit, so the name now says "
|
|
1503
|
+
"which\nfit it belongs to, alongside fit_degree_loess "
|
|
1504
|
+
"and fit_family_loess.")
|
|
1505
|
+
if fit_degree_loess not in (1, 2):
|
|
1506
|
+
raise ValueError("fit_degree_loess: 1 or 2")
|
|
1507
|
+
if fit_family_loess not in ("gaussian", "symmetric"):
|
|
1508
|
+
raise ValueError('fit_family_loess: "gaussian" or "symmetric"')
|
|
1509
|
+
# the loess settings travel together to every fit (R: loess(...,
|
|
1510
|
+
# span, degree, family) in .plt.fit())
|
|
1511
|
+
span = (float(fit_span_loess), int(fit_degree_loess),
|
|
1512
|
+
fit_family_loess)
|
|
1057
1513
|
if plot_errors is not None:
|
|
1058
1514
|
raise ValueError(
|
|
1059
1515
|
"Parameter plot_errors has been renamed to fit_errors. "
|
|
1060
1516
|
"The segments it draws join each point to the fitted line, "
|
|
1061
1517
|
"so they are the errors of the fit, and the name now says "
|
|
1062
1518
|
"which fit they belong to, as fit, fit_color, and fit_se do.")
|
|
1063
|
-
if center_line
|
|
1519
|
+
if center_line not in (None, "off") and facet is not None:
|
|
1064
1520
|
raise ValueError(
|
|
1065
1521
|
"center_line draws on a single panel: no facet=")
|
|
1066
1522
|
if fit_errors:
|
|
@@ -1070,9 +1526,6 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
1070
1526
|
"line: specify fit=")
|
|
1071
1527
|
if form != "scatter":
|
|
1072
1528
|
raise ValueError('fit_errors applies to form="scatter"')
|
|
1073
|
-
if facet is not None:
|
|
1074
|
-
raise ValueError(
|
|
1075
|
-
"fit_errors draws on a single panel: no facet=")
|
|
1076
1529
|
if (scale_x is not None or scale_y is not None) \
|
|
1077
1530
|
and facet is not None:
|
|
1078
1531
|
raise ValueError(
|
|
@@ -1091,16 +1544,13 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
1091
1544
|
raise ValueError(
|
|
1092
1545
|
"outlier flagging (MD_cut/out_cut) applies to "
|
|
1093
1546
|
'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
1547
|
if (n_row is not None or n_col is not None) \
|
|
1098
1548
|
and facet is None:
|
|
1099
1549
|
raise ValueError("n_row and n_col lay out facet panels: "
|
|
1100
1550
|
"specify facet=")
|
|
1101
1551
|
if ts_agg not in ("sum", "mean"):
|
|
1102
1552
|
raise ValueError('ts_agg: "sum" or "mean"')
|
|
1103
|
-
if ts_stack and by is None:
|
|
1553
|
+
if ts_stack and by is None and not isinstance(y, (list, tuple)):
|
|
1104
1554
|
raise ValueError(
|
|
1105
1555
|
"ts_stack stacks multiple time series: specify by=")
|
|
1106
1556
|
if (by is not None and ts_area_fill is not None
|
|
@@ -1112,14 +1562,35 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
1112
1562
|
if ts_ahead > 0:
|
|
1113
1563
|
if ts_source not in ("fable", "classic"):
|
|
1114
1564
|
raise ValueError('ts_source: "fable" or "classic"')
|
|
1115
|
-
if by is not None:
|
|
1116
|
-
raise ValueError(
|
|
1117
|
-
"
|
|
1118
|
-
"so
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
"
|
|
1122
|
-
|
|
1565
|
+
if by is not None: # XY.R: the overlay channel
|
|
1566
|
+
raise ValueError( # is not available
|
|
1567
|
+
"A forecast draws its fit, prediction interval, and "
|
|
1568
|
+
"forecast\nin the series' own colors, so the overlay "
|
|
1569
|
+
"of by= groups is not\navailable to it. To forecast "
|
|
1570
|
+
"each group, use facet=, which\nforecasts each panel "
|
|
1571
|
+
"on one scale.")
|
|
1572
|
+
|
|
1573
|
+
# for the report: the caller's own names, the parameters named in
|
|
1574
|
+
# the call (which the suggestions skip), the digits as given
|
|
1575
|
+
data_in = data
|
|
1576
|
+
digits_user = digits_d
|
|
1577
|
+
x_call = x # as written, keyword included
|
|
1578
|
+
given = {nm for nm, v in (
|
|
1579
|
+
("enhance", enhance or None), ("fill", fill), ("color", color),
|
|
1580
|
+
("fit", None if fit == "off" else fit),
|
|
1581
|
+
("out_cut", out_cut or None), ("MD_cut", MD_cut or None),
|
|
1582
|
+
("pt_shape", None if pt_shape == "circle" else pt_shape),
|
|
1583
|
+
("pt_size", None if pt_size == 1 else pt_size),
|
|
1584
|
+
("line_width", None if line_width == 1.5 else line_width),
|
|
1585
|
+
("ts_ahead", ts_ahead or None), ("ts_unit", ts_unit),
|
|
1586
|
+
("ts_agg", None if ts_agg == "sum" else ts_agg),
|
|
1587
|
+
("ts_seasons", ts_seasons), ("ts_area_fill", ts_area_fill),
|
|
1588
|
+
("ts_stack", ts_stack or None)) if v is not None}
|
|
1589
|
+
show = not resolve_quiet(quiet)
|
|
1590
|
+
|
|
1591
|
+
def say(*comps):
|
|
1592
|
+
if show:
|
|
1593
|
+
print(xc.report(*comps))
|
|
1123
1594
|
|
|
1124
1595
|
if filter is not None:
|
|
1125
1596
|
data = data.query(filter)
|
|
@@ -1150,16 +1621,131 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
1150
1621
|
return scatter_matrix(
|
|
1151
1622
|
mat_df, fit=("off" if fit in ("off", "null")
|
|
1152
1623
|
else fit),
|
|
1153
|
-
digits_d=digits_d, main=main
|
|
1624
|
+
digits_d=digits_d, main=main, form=form,
|
|
1625
|
+
contour_n=contour_n_user,
|
|
1626
|
+
contour_nbins=contour_nbins,
|
|
1627
|
+
contour_points=contour_points, smooth_bins=smooth_bins,
|
|
1628
|
+
smooth_power=smooth_power)
|
|
1629
|
+
# a vector of y on a date x: one series per variable, overlaid
|
|
1630
|
+
# and keyed by name in the legend, drawn as a by= series would
|
|
1631
|
+
# be; the value axis reports only the aggregation and time unit
|
|
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
|
+
|
|
1658
|
+
if y_multi and not x_multi and isinstance(x, str) \
|
|
1659
|
+
and x in data.columns:
|
|
1660
|
+
xd = data[x]
|
|
1661
|
+
if not pd.api.types.is_datetime64_any_dtype(xd) \
|
|
1662
|
+
and not pd.api.types.is_numeric_dtype(xd):
|
|
1663
|
+
from .date_infer import date_infer as _infer_dates
|
|
1664
|
+
try:
|
|
1665
|
+
xd = _infer_dates(xd)
|
|
1666
|
+
except Exception:
|
|
1667
|
+
pass
|
|
1668
|
+
if pd.api.types.is_datetime64_any_dtype(xd):
|
|
1669
|
+
if by is not None:
|
|
1670
|
+
raise ValueError(
|
|
1671
|
+
"A vector of y variables overlays its variables as "
|
|
1672
|
+
"the series of the\nplot, which spends the color "
|
|
1673
|
+
"dimension that by requires.\nTo display a "
|
|
1674
|
+
"grouping variable, use facet=, which panels the "
|
|
1675
|
+
"series.")
|
|
1676
|
+
fcols = ([facet] if isinstance(facet, str) else
|
|
1677
|
+
list(facet) if isinstance(facet, (list, tuple))
|
|
1678
|
+
else [])
|
|
1679
|
+
long = data[[x] + fcols + list(y)].copy()
|
|
1680
|
+
long[x] = xd
|
|
1681
|
+
long = long.melt(id_vars=[x] + fcols, value_vars=list(y),
|
|
1682
|
+
var_name="Series", value_name="Value")
|
|
1683
|
+
long["Series"] = pd.Categorical(long["Series"],
|
|
1684
|
+
categories=list(y))
|
|
1685
|
+
if ylab is None:
|
|
1686
|
+
from .plt_time import _infer_unit
|
|
1687
|
+
ylab = _ts_ylab(None, ts_unit if ts_unit is not None
|
|
1688
|
+
else _infer_unit(long[x].to_numpy()),
|
|
1689
|
+
ts_unit is not None, ts_agg)
|
|
1690
|
+
fig_l = XY(x, "Value", data=long, by="Series", facet=facet,
|
|
1691
|
+
ts_unit=ts_unit, ts_agg=ts_agg,
|
|
1692
|
+
ts_stack=ts_stack, ts_area_fill=ts_area_fill,
|
|
1693
|
+
ts_area_split=ts_area_split, pt_size=pt_size,
|
|
1694
|
+
line_width=line_width, n_row=n_row, n_col=n_col,
|
|
1695
|
+
fill=fill, xlab=xlab, ylab=ylab, main=main,
|
|
1696
|
+
digits_d=digits_d, quiet=True,
|
|
1697
|
+
rotate_x=rotate_x, rotate_y=rotate_y)
|
|
1698
|
+
fig_l.update_layout(legend_title_text="")
|
|
1699
|
+
pre_l, dn_l = xc.call_names(data_in)
|
|
1700
|
+
say(xc.suggest_series(
|
|
1701
|
+
pre_l, x, list(y), dn_l, given,
|
|
1702
|
+
"".join(f', {k}="{v}"' if isinstance(v, str)
|
|
1703
|
+
else f", {k}={v}"
|
|
1704
|
+
for k, v in (("ts_unit", ts_unit),)
|
|
1705
|
+
if v is not None), pt_size=pt_size))
|
|
1706
|
+
return fig_l
|
|
1707
|
+
|
|
1154
1708
|
if x_multi or y_multi:
|
|
1155
|
-
|
|
1709
|
+
# a vector against a categorical variable is Chart()'s dot
|
|
1710
|
+
# chart, named rather than drawn (XY.R)
|
|
1711
|
+
one = y if x_multi else x
|
|
1712
|
+
if (isinstance(one, str) and one in data.columns
|
|
1713
|
+
and not pd.api.types.is_numeric_dtype(data[one])
|
|
1714
|
+
and not pd.api.types.is_datetime64_any_dtype(
|
|
1715
|
+
data[one])):
|
|
1716
|
+
vec = x if x_multi else y
|
|
1717
|
+
vec_s = "[" + ", ".join(f"'{v}'" for v in vec) + "]"
|
|
1718
|
+
raise ValueError(
|
|
1719
|
+
"XY() requires a continuous x and a continuous y.\n"
|
|
1720
|
+
f"{one} is categorical.\n\nFor several values at each "
|
|
1721
|
+
f"level of {one}, use Chart()\nwith a vector of y "
|
|
1722
|
+
f"variables:\n Chart('{one}', y={vec_s}, form='dot')")
|
|
1723
|
+
fig_s = _apply_rotate(_xy_series(
|
|
1156
1724
|
x, y, data, x_multi, by, facet, form, n_row, n_col,
|
|
1157
1725
|
fill, transparency, pt_shape, pt_size,
|
|
1158
1726
|
fit, fit_power, fit_se, fit_lwd, span,
|
|
1159
1727
|
ellipse, ellipse_fill, ellipse_lwd,
|
|
1160
1728
|
MD_cut, out_cut,
|
|
1161
|
-
xlab, ylab, main, digits_d,
|
|
1729
|
+
xlab, ylab, main, digits_d, True),
|
|
1162
1730
|
rotate_x, rotate_y)
|
|
1731
|
+
# the report: one correlation per plotted variable, each
|
|
1732
|
+
# headed by its name (R overwrote all but the last)
|
|
1733
|
+
pre, dname = xc.call_names(data_in)
|
|
1734
|
+
names = list(x) if x_multi else list(y)
|
|
1735
|
+
other = data[y if x_multi else x]
|
|
1736
|
+
comps = [xc.suggest_scatter(pre, x, y, dname, given)]
|
|
1737
|
+
if (fit in ("off", "lm")
|
|
1738
|
+
and pd.api.types.is_numeric_dtype(other)):
|
|
1739
|
+
for nm in names:
|
|
1740
|
+
xv_ = pd.to_numeric(data[nm], errors="coerce")
|
|
1741
|
+
pair = (xv_, other) if x_multi else (other, xv_)
|
|
1742
|
+
head = (f"Variable: {nm} with {y}" if x_multi
|
|
1743
|
+
else f"Variable: {x} with {nm}")
|
|
1744
|
+
comps.append(["", head] + xc.cor_block(
|
|
1745
|
+
pair[0].to_numpy(float), pair[1].to_numpy(float),
|
|
1746
|
+
nm if x_multi else x, y if x_multi else nm))
|
|
1747
|
+
say(*comps)
|
|
1748
|
+
return fig_s
|
|
1163
1749
|
|
|
1164
1750
|
# ----- row_names: each row label identifies a single case, a
|
|
1165
1751
|
# categorical axis that belongs to Chart(form="dot")
|
|
@@ -1176,21 +1762,18 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
1176
1762
|
f" display the values of {cont} directly with Chart():\n"
|
|
1177
1763
|
f" Chart('row_names', y='{cont}', form='dot')")
|
|
1178
1764
|
|
|
1179
|
-
#
|
|
1180
|
-
#
|
|
1181
|
-
# R's stat aggregates y within bins of x (n_bins), not ported
|
|
1765
|
+
# sort and the segments_x/_y droplines served the dot plot XY()
|
|
1766
|
+
# once drew of a categorical variable, now Chart()'s
|
|
1182
1767
|
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}')")
|
|
1768
|
+
if stat not in STAT_FUN:
|
|
1769
|
+
raise ValueError("stat must be one of "
|
|
1770
|
+
+ ", ".join(f'"{k}"' for k in STAT_FUN))
|
|
1771
|
+
if (by is not None or facet is not None
|
|
1772
|
+
or isinstance(x, (list, tuple))
|
|
1773
|
+
or isinstance(y, (list, tuple))):
|
|
1774
|
+
raise ValueError(
|
|
1775
|
+
"stat plots one statistic of y at each value of x: "
|
|
1776
|
+
"no by=, facet=, or vector of variables")
|
|
1194
1777
|
for nm, v in (("sort", None if sort == "0" else sort),
|
|
1195
1778
|
("segments_x", segments_x),
|
|
1196
1779
|
("segments_y", segments_y)):
|
|
@@ -1204,23 +1787,185 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
1204
1787
|
# chart: synthesize it rather than looking up a column
|
|
1205
1788
|
def _resolve(name, arg):
|
|
1206
1789
|
if isinstance(name, str) and name == ".Index":
|
|
1790
|
+
if ".Index" in data.columns: # numbered within series
|
|
1791
|
+
return data[".Index"].rename("Index")
|
|
1207
1792
|
return pd.Series(range(1, len(data) + 1),
|
|
1208
1793
|
index=data.index, name="Index")
|
|
1209
1794
|
return get_column(data, name, arg)
|
|
1210
1795
|
|
|
1796
|
+
# a keyword in the x role names no variable, so it is matched
|
|
1797
|
+
# without regard to case: .index for the run chart, .normal,
|
|
1798
|
+
# .lognormal, .exponential, .uniform for a q-q chart (XY.R)
|
|
1799
|
+
qq_key = None
|
|
1800
|
+
if (isinstance(x, str) and x.startswith(".")
|
|
1801
|
+
and x not in data.columns):
|
|
1802
|
+
key = x.lower()
|
|
1803
|
+
if key == ".index":
|
|
1804
|
+
x = ".Index"
|
|
1805
|
+
elif key in _QQ_LAB:
|
|
1806
|
+
qq_key = key
|
|
1807
|
+
else:
|
|
1808
|
+
raise ValueError(
|
|
1809
|
+
"A name in the x role that begins with a period is a "
|
|
1810
|
+
f"keyword\nfor a generated variable, but {x} is not one "
|
|
1811
|
+
"of\nthe keywords, and is not a variable in the data.\n\n"
|
|
1812
|
+
"Available keywords, matched without regard to case:\n"
|
|
1813
|
+
" .index generates the consecutive integers of a run "
|
|
1814
|
+
"chart\n " + ", ".join(_QQ_LAB) + " each generates\n"
|
|
1815
|
+
" the theoretical quantiles of the corresponding "
|
|
1816
|
+
"q-q chart")
|
|
1817
|
+
if qq_key is not None and (isinstance(y, (list, tuple))
|
|
1818
|
+
or form != "scatter"):
|
|
1819
|
+
raise ValueError(
|
|
1820
|
+
"A q-q chart plots one y variable as a scatterplot "
|
|
1821
|
+
'against its theoretical quantiles: one y, form="scatter"')
|
|
1822
|
+
|
|
1211
1823
|
index_x = isinstance(x, str) and x == ".Index"
|
|
1212
1824
|
index_y = isinstance(y, str) and y == ".Index"
|
|
1213
|
-
x_ser = _resolve(x, "x")
|
|
1214
1825
|
y_ser = _resolve(y, "y")
|
|
1826
|
+
x_ser = (pd.Series(np.nan, index=data.index, name=_QQ_LAB[qq_key])
|
|
1827
|
+
if qq_key is not None else _resolve(x, "x"))
|
|
1828
|
+
if qq_key is not None:
|
|
1829
|
+
x = _QQ_LAB[qq_key]
|
|
1215
1830
|
if index_x: # for axis labels below
|
|
1216
1831
|
x = "Index"
|
|
1217
1832
|
if index_y:
|
|
1218
1833
|
y = "Index"
|
|
1219
1834
|
|
|
1220
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'")
|
|
1221
1860
|
(facet_ser, facet_name,
|
|
1222
1861
|
facet2_ser, facet2_name) = resolve_facet(data, facet, "XY")
|
|
1223
1862
|
|
|
1863
|
+
# q-q: the quantiles of y, regenerated within each group of by
|
|
1864
|
+
# and each panel of facet, so every group reads against its own
|
|
1865
|
+
# distribution; both axes then share one range (XY.R)
|
|
1866
|
+
if qq_key is not None:
|
|
1867
|
+
if not pd.api.types.is_numeric_dtype(y_ser):
|
|
1868
|
+
raise ValueError(
|
|
1869
|
+
"A q-q chart displays the distribution of a continuous "
|
|
1870
|
+
"variable,\nbut the y variable is not numeric.")
|
|
1871
|
+
grp = None
|
|
1872
|
+
for g in (by_ser, facet_ser, facet2_ser):
|
|
1873
|
+
if g is not None:
|
|
1874
|
+
gs = g.astype(str).where(g.notna())
|
|
1875
|
+
grp = gs if grp is None else grp + "\x1f" + gs
|
|
1876
|
+
yv = y_ser.to_numpy(dtype=float)
|
|
1877
|
+
if grp is None:
|
|
1878
|
+
qv = _qq_quantiles(yv, qq_key)
|
|
1879
|
+
else:
|
|
1880
|
+
qv = np.full(len(yv), np.nan)
|
|
1881
|
+
for lv in grp.dropna().unique():
|
|
1882
|
+
i = (grp == lv).to_numpy()
|
|
1883
|
+
if (~np.isnan(yv[i])).sum() >= 3:
|
|
1884
|
+
qv[i] = _qq_quantiles(yv[i], qq_key)
|
|
1885
|
+
x_ser = pd.Series(qv, index=data.index, name=x)
|
|
1886
|
+
if scale_x is None and scale_y is None:
|
|
1887
|
+
pr = pretty(float(np.nanmin(np.r_[qv, yv])),
|
|
1888
|
+
float(np.nanmax(np.r_[qv, yv])))
|
|
1889
|
+
scale_x = scale_y = (pr[0], pr[-1], len(pr) - 1)
|
|
1890
|
+
|
|
1891
|
+
# the report (x_console): ports of R's printers, built as the
|
|
1892
|
+
# display is; xy_stats() records the numbers for fig.stats
|
|
1893
|
+
pre, dname = xc.call_names(data_in)
|
|
1894
|
+
_lbls = ((getattr(data_in, "attrs", {}) or {})
|
|
1895
|
+
.get("variable_labels", {}) or {})
|
|
1896
|
+
x_lbl, y_lbl = _lbls.get(x), _lbls.get(y)
|
|
1897
|
+
dd_r = (xc.xy_digits(pd.to_numeric(y_ser, errors="coerce")
|
|
1898
|
+
.to_numpy(float), digits_user)
|
|
1899
|
+
if not isinstance(y, (list, tuple)) else 3)
|
|
1900
|
+
|
|
1901
|
+
def scatter_report(groups_, fit_stats_, md_lines_):
|
|
1902
|
+
xy_stats(groups_, x, y, fit_stats_, digits_d, by_name=by)
|
|
1903
|
+
if stat_lines is not None: # R reports the levels alone
|
|
1904
|
+
return [stat_lines]
|
|
1905
|
+
comps = [xc.suggest_scatter(pre, x_call, y, dname, given,
|
|
1906
|
+
by=by)]
|
|
1907
|
+
if md_lines_ is not None:
|
|
1908
|
+
comps.append(md_lines_)
|
|
1909
|
+
# correlation for no fit or a linear one, not with by, not
|
|
1910
|
+
# for a q-q chart (.plt.txt)
|
|
1911
|
+
if fit in ("off", "lm") and by is None and qq_key is None:
|
|
1912
|
+
for _, xg, yg in groups_:
|
|
1913
|
+
comps.append(xc.cor_block(xg, yg, x, y, x_lbl, y_lbl))
|
|
1914
|
+
# the fit that enhance= supplies is drawn, not reported (R)
|
|
1915
|
+
if (fit not in ("off", "null") and fit_stats_
|
|
1916
|
+
and not (enhance and "fit" not in given)):
|
|
1917
|
+
comps.append(xc.fit_text(fit_stats_, fit, fit_power, y,
|
|
1918
|
+
dd_r, by_name=by))
|
|
1919
|
+
return comps
|
|
1920
|
+
|
|
1921
|
+
def facet_report(xv_s):
|
|
1922
|
+
"""XY.R's summary of x per grouping variable; x's own
|
|
1923
|
+
decimals, at least 2, as X() now gives them"""
|
|
1924
|
+
sd = max(xc.max_dd(xv_s), 2)
|
|
1925
|
+
summ = ["", f"---------- Summary Statistics for {x}"]
|
|
1926
|
+
for g, nm in ((by_ser, by), (facet_ser, facet_name),
|
|
1927
|
+
(facet2_ser, facet2_name)):
|
|
1928
|
+
if g is not None:
|
|
1929
|
+
summ += [""] + xc.vbs_summary(
|
|
1930
|
+
xv_s, g.to_numpy(), nm, sd,
|
|
1931
|
+
order=category_order(g))
|
|
1932
|
+
return summ
|
|
1933
|
+
|
|
1934
|
+
# stat: the statistic of y at each value of a continuous x, the
|
|
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
|
+
|
|
1947
|
+
stat_lines = None
|
|
1948
|
+
if stat is not None:
|
|
1949
|
+
for a_, b_ in ((x, y), (y, x)):
|
|
1950
|
+
if not pd.api.types.is_numeric_dtype(data[a_]):
|
|
1951
|
+
raise ValueError(
|
|
1952
|
+
"stat in XY() plots a statistic of y at each value "
|
|
1953
|
+
"of a continuous x. For a statistic of a numerical "
|
|
1954
|
+
"variable across the levels of a categorical one, "
|
|
1955
|
+
f"use Chart():\n Chart('{a_}', y='{b_}', "
|
|
1956
|
+
f"stat='{stat}')")
|
|
1957
|
+
okx = x_ser.notna()
|
|
1958
|
+
stat_lines = _stat_by_levels(
|
|
1959
|
+
pd.to_numeric(y_ser[okx]), x_ser[okx], y, x,
|
|
1960
|
+
max(xc.max_dd(y_ser.to_numpy(float)) + 1, 2))
|
|
1961
|
+
agg = (pd.to_numeric(y_ser[okx]).groupby(x_ser[okx])
|
|
1962
|
+
.agg(STAT_FUN[stat]).dropna())
|
|
1963
|
+
data = pd.DataFrame({x: agg.index.to_numpy(float),
|
|
1964
|
+
y: agg.to_numpy(float)})
|
|
1965
|
+
x_ser, y_ser = data[x], data[y]
|
|
1966
|
+
if ylab is None:
|
|
1967
|
+
ylab = f"{STAT_LBL[stat]} of {y}"
|
|
1968
|
+
|
|
1224
1969
|
# facet needs a categorical variable; a numeric one with more
|
|
1225
1970
|
# than 25 distinct values is almost surely continuous, so stop
|
|
1226
1971
|
# and point to a category conversion (XY.R ~1168). A category
|
|
@@ -1259,6 +2004,16 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
1259
2004
|
except (ValueError, TypeError): # not a date column
|
|
1260
2005
|
pass
|
|
1261
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
|
+
|
|
1262
2017
|
is_date = pd.api.types.is_datetime64_any_dtype(x_ser)
|
|
1263
2018
|
# XY() displays the relationship of two continuous variables; a
|
|
1264
2019
|
# categorical x or y belongs to another view, so name its call
|
|
@@ -1302,10 +2057,12 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
1302
2057
|
y_ser = y_ser.fillna(ts_NA)
|
|
1303
2058
|
|
|
1304
2059
|
used = [s for s in (x_ser, y_ser, by_ser, facet_ser,
|
|
1305
|
-
facet2_ser)
|
|
2060
|
+
facet2_ser, size_ser)
|
|
1306
2061
|
if s is not None]
|
|
1307
2062
|
keep = ~pd.concat(used, axis=1).isna().any(axis=1)
|
|
1308
2063
|
x_ser, y_ser = x_ser[keep], y_ser[keep]
|
|
2064
|
+
if size_ser is not None:
|
|
2065
|
+
size_ser = size_ser[keep]
|
|
1309
2066
|
if by_ser is not None:
|
|
1310
2067
|
by_ser = by_ser[keep]
|
|
1311
2068
|
if facet_ser is not None:
|
|
@@ -1313,11 +2070,50 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
1313
2070
|
if facet2_ser is not None:
|
|
1314
2071
|
facet2_ser = facet2_ser[keep]
|
|
1315
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")
|
|
1316
2077
|
if is_date and form != "scatter":
|
|
1317
2078
|
raise ValueError(
|
|
1318
2079
|
f'form="{form}" describes the joint density of two '
|
|
1319
2080
|
"numerical variables, not a time series")
|
|
1320
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
|
+
|
|
1321
2117
|
if is_date: # time series: date order
|
|
1322
2118
|
od = x_ser.sort_values(kind="stable").index
|
|
1323
2119
|
x_ser, y_ser = x_ser.loc[od], y_ser.loc[od]
|
|
@@ -1327,10 +2123,15 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
1327
2123
|
facet_ser = facet_ser.loc[od]
|
|
1328
2124
|
if facet2_ser is not None:
|
|
1329
2125
|
facet2_ser = facet2_ser.loc[od]
|
|
1330
|
-
if
|
|
1331
|
-
raise
|
|
1332
|
-
"
|
|
1333
|
-
"
|
|
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=")
|
|
1334
2135
|
if MD_cut > 0 or out_cut > 0:
|
|
1335
2136
|
raise ValueError(
|
|
1336
2137
|
"outlier flagging (MD_cut/out_cut) applies to a "
|
|
@@ -1340,17 +2141,29 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
1340
2141
|
"forecasting (ts_ahead) requires a date variable "
|
|
1341
2142
|
"for x")
|
|
1342
2143
|
|
|
1343
|
-
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):
|
|
1344
2146
|
raise ValueError(
|
|
1345
2147
|
"ts_stack and ts_area_fill apply to a time series "
|
|
1346
|
-
"(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")
|
|
1347
2152
|
|
|
1348
2153
|
# ----- ts aggregation (ts_unit/ts_agg) --------------------------
|
|
1349
|
-
|
|
2154
|
+
# facets aggregate within each panel, below
|
|
2155
|
+
ts_agg_done = is_date and ts_unit is not None and facet_ser is None
|
|
2156
|
+
if ts_agg_done:
|
|
1350
2157
|
x_ser, y_ser, by_ser, ts_unit = plt_time(
|
|
1351
2158
|
x_ser, y_ser, by_ser, ts_unit, ts_agg,
|
|
1352
2159
|
quiet=resolve_quiet(quiet))
|
|
1353
|
-
if is_date and
|
|
2160
|
+
if is_date and ylab is None and facet_ser is None: # a given
|
|
2161
|
+
# ylab names the axis
|
|
2162
|
+
from .plt_time import _infer_unit
|
|
2163
|
+
ylab = _ts_ylab(
|
|
2164
|
+
y, ts_unit if ts_unit is not None
|
|
2165
|
+
else _infer_unit(x_ser.to_numpy()), ts_agg_done, ts_agg)
|
|
2166
|
+
if (is_date or index_x) and (ts_stack or ts_area_fill is not None) \
|
|
1354
2167
|
and pt_size == 1:
|
|
1355
2168
|
pt_size = 0 # R: no points with areas
|
|
1356
2169
|
|
|
@@ -1377,6 +2190,87 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
1377
2190
|
add_means = (form in ("scatter", "smooth")
|
|
1378
2191
|
and facet is None)
|
|
1379
2192
|
|
|
2193
|
+
# ----- sunflower: petals where observations coincide ----------
|
|
2194
|
+
if form == "sunflower":
|
|
2195
|
+
if is_date:
|
|
2196
|
+
raise ValueError(
|
|
2197
|
+
'form="sunflower" plots two continuous variables, '
|
|
2198
|
+
"not a time series")
|
|
2199
|
+
if ellipse is True:
|
|
2200
|
+
ellipse = 0.95
|
|
2201
|
+
f_col = as_plotly_color(get_option("fit_color", "#5C4032")
|
|
2202
|
+
if fit_color is None else fit_color)
|
|
2203
|
+
f_lwd = get_option("fit_lwd", 2) if fit_lwd is None else fit_lwd
|
|
2204
|
+
e_lvls = ([] if not ellipse else
|
|
2205
|
+
list(ellipse) if isinstance(ellipse, (list, tuple))
|
|
2206
|
+
else [ellipse])
|
|
2207
|
+
|
|
2208
|
+
def overlay(fig_, xv_, yv_, row, col):
|
|
2209
|
+
at = {} if row is None else dict(row=row, col=col)
|
|
2210
|
+
for lv in e_lvls:
|
|
2211
|
+
ex, ey = _ellipse_region(xv_, yv_, float(lv))
|
|
2212
|
+
fig_.add_trace(go.Scatter(
|
|
2213
|
+
x=ex, y=ey, mode="lines",
|
|
2214
|
+
line=dict(color=as_plotly_color(
|
|
2215
|
+
get_option("ellipse_color", "gray20")
|
|
2216
|
+
if ellipse_color is None else ellipse_color),
|
|
2217
|
+
width=get_option("ellipse_lwd", 1)
|
|
2218
|
+
if ellipse_lwd is None else ellipse_lwd),
|
|
2219
|
+
hoverinfo="skip", showlegend=False), **at)
|
|
2220
|
+
if fit not in ("off", "null"):
|
|
2221
|
+
if fit == "loess":
|
|
2222
|
+
xs_, _, f_, _ = _loess(xv_, yv_, span)
|
|
2223
|
+
else:
|
|
2224
|
+
xs_, _, f_ = _plt_fit(xv_, yv_, fit, fit_power)
|
|
2225
|
+
fig_.add_trace(go.Scatter(
|
|
2226
|
+
x=xs_, y=f_, mode="lines",
|
|
2227
|
+
line=dict(color=f_col, width=f_lwd),
|
|
2228
|
+
hoverinfo="skip", showlegend=False), **at)
|
|
2229
|
+
|
|
2230
|
+
xv_f = x_ser.to_numpy(dtype=float)
|
|
2231
|
+
yv_f = y_ser.to_numpy(dtype=float)
|
|
2232
|
+
if facet is not None:
|
|
2233
|
+
n_lvl = len(category_order(facet_ser))
|
|
2234
|
+
n_col_sf = (int(n_col) if n_col is not None else
|
|
2235
|
+
math.ceil(n_lvl / int(n_row)) if n_row
|
|
2236
|
+
is not None else math.ceil(math.sqrt(n_lvl)))
|
|
2237
|
+
fig = sunflower_plotly(
|
|
2238
|
+
xv_f, yv_f, x if xlab is None else xlab,
|
|
2239
|
+
y if ylab is None else ylab, main,
|
|
2240
|
+
facet=facet_ser.to_numpy(),
|
|
2241
|
+
facet_order=category_order(facet_ser),
|
|
2242
|
+
facet_name=facet_name,
|
|
2243
|
+
facet2=(None if facet2_ser is None
|
|
2244
|
+
else facet2_ser.to_numpy()),
|
|
2245
|
+
facet2_order=(None if facet2_ser is None
|
|
2246
|
+
else category_order(facet2_ser)),
|
|
2247
|
+
facet2_name=facet2_name, n_col=n_col_sf,
|
|
2248
|
+
pt_size=pt_size, fill=fill, color=color,
|
|
2249
|
+
overlay=overlay,
|
|
2250
|
+
digits_d=2 if digits_d is None else digits_d,
|
|
2251
|
+
axis_fmt=axis_fmt, axis_x_pre=axis_x_pre,
|
|
2252
|
+
axis_y_pre=axis_y_pre)
|
|
2253
|
+
xy_stats([(None, xv_f, yv_f)], x, y,
|
|
2254
|
+
digits_d=2 if digits_d is None else digits_d)
|
|
2255
|
+
say(facet_report(xv_f))
|
|
2256
|
+
else:
|
|
2257
|
+
fig = sunflower_plotly(
|
|
2258
|
+
xv_f, yv_f, x if xlab is None else xlab,
|
|
2259
|
+
y if ylab is None else ylab, main, pt_size=pt_size,
|
|
2260
|
+
fill=fill, color=color, overlay=overlay,
|
|
2261
|
+
digits_d=2 if digits_d is None else digits_d,
|
|
2262
|
+
axis_fmt=axis_fmt, axis_x_pre=axis_x_pre,
|
|
2263
|
+
axis_y_pre=axis_y_pre)
|
|
2264
|
+
fs = []
|
|
2265
|
+
if fit not in ("off", "null"):
|
|
2266
|
+
if fit == "loess":
|
|
2267
|
+
xs_, ys_, f_, _ = _loess(xv_f, yv_f, span)
|
|
2268
|
+
else:
|
|
2269
|
+
xs_, ys_, f_ = _plt_fit(xv_f, yv_f, fit, fit_power)
|
|
2270
|
+
fs = [(None, fit, ys_, f_, xs_)]
|
|
2271
|
+
say(*scatter_report([(None, xv_f, yv_f)], fs, None))
|
|
2272
|
+
return _apply_rotate(fig, rotate_x, rotate_y)
|
|
2273
|
+
|
|
1380
2274
|
# ----- facet: one panel per level -----------------------------
|
|
1381
2275
|
if facet is not None:
|
|
1382
2276
|
facet2_arr = f2_order = None
|
|
@@ -1399,12 +2293,10 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
1399
2293
|
if form in ("contour", "smooth"):
|
|
1400
2294
|
if ellipse is True:
|
|
1401
2295
|
ellipse = 0.95
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
digits_d=2 if digits_d is None
|
|
1407
|
-
else digits_d)))
|
|
2296
|
+
xy_stats([(None, x_ser.to_numpy(dtype=float),
|
|
2297
|
+
y_ser.to_numpy(dtype=float))], x, y,
|
|
2298
|
+
digits_d=2 if digits_d is None else digits_d)
|
|
2299
|
+
say(facet_report(x_ser.to_numpy(dtype=float)))
|
|
1408
2300
|
return _apply_rotate(plt_contour_facet(
|
|
1409
2301
|
x_ser.to_numpy(dtype=float),
|
|
1410
2302
|
y_ser.to_numpy(dtype=float),
|
|
@@ -1433,11 +2325,57 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
1433
2325
|
n_col=n_col_use, axis_fmt=axis_fmt,
|
|
1434
2326
|
axis_x_pre=axis_x_pre, axis_y_pre=axis_y_pre),
|
|
1435
2327
|
rotate_x, rotate_y)
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
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):
|
|
2331
|
+
# ts_unit aggregates within each panel and group; the
|
|
2332
|
+
# forecast is of each panel's series (XY.R, Aug 2026)
|
|
2333
|
+
f_arr = facet_ser.to_numpy()
|
|
2334
|
+
ts_by = by_ser.to_numpy() if by_ser is not None else None
|
|
2335
|
+
agg_done = ts_unit is not None
|
|
2336
|
+
if agg_done:
|
|
2337
|
+
parts = [s_.astype(str).to_numpy() for s_ in
|
|
2338
|
+
(facet_ser, facet2_ser, by_ser)
|
|
2339
|
+
if s_ is not None]
|
|
2340
|
+
key = pd.Series(["\x1f".join(t) for t in zip(*parts)],
|
|
2341
|
+
index=x_ser.index)
|
|
2342
|
+
xa, ya, ka, ts_unit = plt_time(
|
|
2343
|
+
x_ser, y_ser, key, ts_unit, ts_agg,
|
|
2344
|
+
quiet=resolve_quiet(quiet))
|
|
2345
|
+
cols = list(zip(*[str(k).split("\x1f")
|
|
2346
|
+
for k in ka.astype(str)]))
|
|
2347
|
+
x_ser, y_ser = xa, ya
|
|
2348
|
+
f_arr = np.asarray(cols[0], dtype=object)
|
|
2349
|
+
ci = 1
|
|
2350
|
+
if facet2_ser is not None:
|
|
2351
|
+
facet2_arr = np.asarray(cols[ci], dtype=object)
|
|
2352
|
+
ci += 1
|
|
2353
|
+
if by_ser is not None:
|
|
2354
|
+
ts_by = np.asarray(cols[ci], dtype=object)
|
|
2355
|
+
f_order = [str(v) for v in category_order(facet_ser)]
|
|
2356
|
+
f_arr = np.asarray(f_arr).astype(str)
|
|
2357
|
+
if ylab is None and is_date:
|
|
2358
|
+
from .plt_time import _infer_unit
|
|
2359
|
+
ylab = _ts_ylab(y, ts_unit if ts_unit is not None
|
|
2360
|
+
else _infer_unit(x_ser.to_numpy()),
|
|
2361
|
+
agg_done, ts_agg)
|
|
2362
|
+
frcsts = {}
|
|
2363
|
+
if ts_ahead > 0:
|
|
2364
|
+
xs_all = x_ser.to_numpy()
|
|
2365
|
+
ys_all = y_ser.to_numpy(dtype=float)
|
|
2366
|
+
for i_f, lv in enumerate(f_order):
|
|
2367
|
+
mm = f_arr == lv
|
|
2368
|
+
if mm.sum() < 4:
|
|
2369
|
+
continue
|
|
2370
|
+
od = np.argsort(xs_all[mm], kind="stable")
|
|
2371
|
+
frcsts[i_f] = plt_forecast(
|
|
2372
|
+
xs_all[mm][od], ys_all[mm][od], x, y,
|
|
2373
|
+
ts_unit=ts_unit, ts_ahead=ts_ahead,
|
|
2374
|
+
ts_method=ts_method, ts_source=ts_source,
|
|
2375
|
+
ts_error=ts_error, ts_trend=ts_trend,
|
|
2376
|
+
ts_seasons=ts_seasons, ts_alpha=ts_alpha,
|
|
2377
|
+
ts_beta=ts_beta, ts_gamma=ts_gamma,
|
|
2378
|
+
ts_PI=ts_PI, digits_d=digits_d)
|
|
1441
2379
|
if transparency is None:
|
|
1442
2380
|
transparency = get_option("trans_pt_fill", 0.10)
|
|
1443
2381
|
fill0 = (get_option("pt_color", "#324E5C")
|
|
@@ -1451,7 +2389,7 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
1451
2389
|
ts_fig = _ts_facet(
|
|
1452
2390
|
x_ser.to_numpy(),
|
|
1453
2391
|
y_ser.to_numpy(dtype=float),
|
|
1454
|
-
|
|
2392
|
+
f_arr, f_order,
|
|
1455
2393
|
fill0, border0, pt_size, 1 - transparency,
|
|
1456
2394
|
x if xlab is None else xlab,
|
|
1457
2395
|
y if ylab is None else ylab,
|
|
@@ -1460,9 +2398,22 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
1460
2398
|
area_split=float(ts_area_split),
|
|
1461
2399
|
facet_name=facet_name, facet2_arr=facet2_arr,
|
|
1462
2400
|
facet2_order=f2_order,
|
|
1463
|
-
facet2_name=facet2_name, n_col=n_col_use
|
|
2401
|
+
facet2_name=facet2_name, n_col=n_col_use,
|
|
2402
|
+
by_arr=ts_by,
|
|
2403
|
+
by_order=(None if by_ser is None else
|
|
2404
|
+
[str(v) for v in category_order(by_ser)]),
|
|
2405
|
+
by_name=by, line_width=line_width,
|
|
2406
|
+
frcsts=frcsts, ts_PI=ts_PI)
|
|
1464
2407
|
if ts_n_x_tics is not None: # date-axis tick count
|
|
1465
2408
|
ts_fig.update_xaxes(nticks=int(ts_n_x_tics))
|
|
2409
|
+
if show:
|
|
2410
|
+
for i_f, fr in frcsts.items():
|
|
2411
|
+
print(f"\n{facet_name}: {f_order[i_f]}")
|
|
2412
|
+
print("\n".join(fr["report"]))
|
|
2413
|
+
print("Forecast\n--------")
|
|
2414
|
+
print(fr["forecast"].to_string(
|
|
2415
|
+
float_format=lambda v:
|
|
2416
|
+
f"{v:.{(2 if digits_d is None else digits_d) + 2}f}"))
|
|
1466
2417
|
return _apply_rotate(ts_fig, rotate_x, rotate_y)
|
|
1467
2418
|
by_arr = by_order = None
|
|
1468
2419
|
if by_ser is not None:
|
|
@@ -1472,8 +2423,7 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
1472
2423
|
if fill is None:
|
|
1473
2424
|
fills_f = ([get_option("pt_color", "#324E5C")]
|
|
1474
2425
|
if n_grp == 1
|
|
1475
|
-
else
|
|
1476
|
-
for i in range(n_grp)])
|
|
2426
|
+
else by_colors(n_grp))
|
|
1477
2427
|
else:
|
|
1478
2428
|
fills_f = (list(fill)
|
|
1479
2429
|
if isinstance(fill, (list, tuple))
|
|
@@ -1482,35 +2432,18 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
1482
2432
|
for i in range(n_grp)]
|
|
1483
2433
|
if transparency is None:
|
|
1484
2434
|
transparency = get_option("trans_pt_fill", 0.10)
|
|
1485
|
-
if fit_se is None:
|
|
1486
|
-
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)
|
|
1487
2437
|
se_lv = [lv for lv in (
|
|
1488
2438
|
fit_se if isinstance(fit_se, (list, tuple))
|
|
1489
2439
|
else [fit_se]) if lv]
|
|
1490
2440
|
if ellipse is True:
|
|
1491
2441
|
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(
|
|
2442
|
+
xv_s = x_ser.to_numpy(dtype=float)
|
|
2443
|
+
xy_stats([(None, xv_s, y_ser.to_numpy(dtype=float))],
|
|
2444
|
+
x, y, digits_d=2 if digits_d is None else digits_d)
|
|
2445
|
+
say(facet_report(xv_s))
|
|
2446
|
+
fig_f = _apply_rotate(_xy_facet(
|
|
1514
2447
|
x_ser.to_numpy(dtype=float),
|
|
1515
2448
|
y_ser.to_numpy(dtype=float),
|
|
1516
2449
|
by_arr, by_order,
|
|
@@ -1533,8 +2466,12 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
1533
2466
|
y if ylab is None else ylab,
|
|
1534
2467
|
main, 2 if digits_d is None else digits_d,
|
|
1535
2468
|
facet2_arr=facet2_arr, facet2_order=f2_order,
|
|
1536
|
-
facet2_name=facet2_name, n_col=n_col_use
|
|
2469
|
+
facet2_name=facet2_name, n_col=n_col_use,
|
|
2470
|
+
fit_errors=fit_errors),
|
|
1537
2471
|
rotate_x, rotate_y)
|
|
2472
|
+
if qq_key is not None:
|
|
2473
|
+
_qq_reference(fig_f, fit_color, fit_lwd)
|
|
2474
|
+
return fig_f
|
|
1538
2475
|
|
|
1539
2476
|
# ----- groups and colors --------------------------------------
|
|
1540
2477
|
xv = x_ser.to_numpy() if is_date \
|
|
@@ -1548,6 +2485,27 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
1548
2485
|
groups = [(nm, xv[bv == nm], yv[bv == nm])
|
|
1549
2486
|
for nm in by_order]
|
|
1550
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
|
+
|
|
1551
2509
|
# ----- jitter: the display coordinates only --------------------
|
|
1552
2510
|
# auto-jitter an axis with few discrete values (R plt.main.R
|
|
1553
2511
|
# ~634-659); the fit, ellipse, and statistics use the
|
|
@@ -1592,9 +2550,9 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
1592
2550
|
n_grp = len(groups)
|
|
1593
2551
|
if fill is None:
|
|
1594
2552
|
fills = ([get_option("pt_color", "#324E5C")] if n_grp == 1
|
|
1595
|
-
else
|
|
1596
|
-
for i in range(n_grp)])
|
|
2553
|
+
else by_colors(n_grp))
|
|
1597
2554
|
else:
|
|
2555
|
+
fill = _expand_palette(fill, n_grp)
|
|
1598
2556
|
fills = list(fill) if isinstance(fill, (list, tuple)) \
|
|
1599
2557
|
else [fill]
|
|
1600
2558
|
borders = None if color is None else color
|
|
@@ -1616,6 +2574,7 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
1616
2574
|
"ts_stack requires the same dates at every "
|
|
1617
2575
|
"level of by=")
|
|
1618
2576
|
if ts_area_fill is not None:
|
|
2577
|
+
ts_area_fill = _expand_palette(ts_area_fill, n_grp)
|
|
1619
2578
|
a_base = (list(ts_area_fill)
|
|
1620
2579
|
if isinstance(ts_area_fill, (list, tuple))
|
|
1621
2580
|
else [ts_area_fill])
|
|
@@ -1642,7 +2601,7 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
1642
2601
|
cum = yc
|
|
1643
2602
|
groups = stacked
|
|
1644
2603
|
disp_groups = stacked
|
|
1645
|
-
elif is_date and ts_area_fill is not None:
|
|
2604
|
+
elif (is_date or index_x) and ts_area_fill is not None:
|
|
1646
2605
|
nm0, xg, yg = groups[0]
|
|
1647
2606
|
lo, hi = float(yg.min()), float(yg.max())
|
|
1648
2607
|
pad_a = 0.04 * (hi - lo) if hi > lo else 1.0
|
|
@@ -1670,17 +2629,22 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
1670
2629
|
fit_lines, se_polys, fit_stats = [], [], []
|
|
1671
2630
|
err_lines = [] # fit_errors residual segments
|
|
1672
2631
|
if fit != "off":
|
|
1673
|
-
|
|
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):
|
|
1674
2638
|
if len(xg) < 2:
|
|
1675
2639
|
continue
|
|
1676
2640
|
if fit == "loess": # R: bands for lm and loess
|
|
1677
2641
|
xs, ys_s, f, se_f = _loess(xg, yg, span)
|
|
1678
2642
|
fit_lines.append({"name": nm, "x": xs, "y": f})
|
|
1679
|
-
fit_stats.append((nm, fit, ys_s, f))
|
|
2643
|
+
fit_stats.append((nm, fit, ys_s, f, xs))
|
|
1680
2644
|
if fit_errors:
|
|
1681
2645
|
err_lines.append((xs, ys_s, f))
|
|
1682
2646
|
for lv in se_levels:
|
|
1683
|
-
tq = sps.t.ppf((1 + lv) / 2,
|
|
2647
|
+
tq = sps.t.ppf((1 + lv) / 2, _loess.df)
|
|
1684
2648
|
se_polys.append((
|
|
1685
2649
|
np.concatenate([xs, xs[::-1]]),
|
|
1686
2650
|
np.concatenate([f + tq * se_f,
|
|
@@ -1690,28 +2654,41 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
1690
2654
|
okf = np.isfinite(f) # exp/log back-transform NaN
|
|
1691
2655
|
fit_lines.append({"name": nm, "x": xs[okf],
|
|
1692
2656
|
"y": f[okf]})
|
|
1693
|
-
fit_stats.append((nm, fit, ys_s[okf], f[okf]))
|
|
2657
|
+
fit_stats.append((nm, fit, ys_s[okf], f[okf], xs[okf]))
|
|
1694
2658
|
if fit_errors:
|
|
1695
2659
|
err_lines.append((xs[okf], ys_s[okf], f[okf]))
|
|
1696
2660
|
if fit == "lm":
|
|
1697
2661
|
for lv in se_levels:
|
|
1698
|
-
|
|
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]
|
|
1699
2675
|
|
|
1700
2676
|
# ----- ellipses ------------------------------------------------
|
|
1701
2677
|
if ellipse is True:
|
|
1702
2678
|
ellipse = 0.95
|
|
1703
2679
|
ellipses = []
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
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:
|
|
1708
2690
|
for nm, xg, yg in groups:
|
|
1709
|
-
|
|
1710
|
-
who = "" if nm is None else f" for group '{nm}'"
|
|
1711
|
-
raise ValueError(
|
|
1712
|
-
f"ellipse: need at least 3 points{who}, "
|
|
1713
|
-
f"found {len(xg)}")
|
|
1714
|
-
ellipses.append(_ellipse_region(xg, yg, ellipse))
|
|
2691
|
+
ellipses.append(_ellipse_region(xg, yg, lv))
|
|
1715
2692
|
|
|
1716
2693
|
if digits_d is None:
|
|
1717
2694
|
digits_d = 2
|
|
@@ -1740,11 +2717,7 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
1740
2717
|
axis_fmt=axis_fmt, axis_x_pre=axis_x_pre,
|
|
1741
2718
|
axis_y_pre=axis_y_pre)
|
|
1742
2719
|
_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))
|
|
2720
|
+
say(*scatter_report(groups, fit_stats, md_lines))
|
|
1748
2721
|
return fig
|
|
1749
2722
|
|
|
1750
2723
|
# ----- forecast (date x, ts_source="classic") -------------------
|
|
@@ -1767,7 +2740,7 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
1767
2740
|
frcst["y_lwr"], frcst["y_upr"]] if frcst else []))
|
|
1768
2741
|
if scale_y is not None: # explicit y scale
|
|
1769
2742
|
axT2 = np.linspace(float(scale_y[0]), float(scale_y[1]),
|
|
1770
|
-
int(scale_y[2]))
|
|
2743
|
+
int(scale_y[2]) + 1) # n intervals
|
|
1771
2744
|
else:
|
|
1772
2745
|
axT2 = pretty(float(np.nanmin(ys_all)),
|
|
1773
2746
|
float(np.nanmax(ys_all)))
|
|
@@ -1783,7 +2756,7 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
1783
2756
|
if scale_x is not None: # explicit x scale
|
|
1784
2757
|
axT1 = np.linspace(float(scale_x[0]),
|
|
1785
2758
|
float(scale_x[1]),
|
|
1786
|
-
int(scale_x[2]))
|
|
2759
|
+
int(scale_x[2]) + 1) # n intervals
|
|
1787
2760
|
else:
|
|
1788
2761
|
axT1 = pretty(float(np.nanmin(xs_all)),
|
|
1789
2762
|
float(np.nanmax(xs_all)))
|
|
@@ -1832,19 +2805,19 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
1832
2805
|
_apply_rotate(fig, rotate_x, rotate_y)
|
|
1833
2806
|
if add_means:
|
|
1834
2807
|
_add_means(fig, xv, yv)
|
|
1835
|
-
|
|
1836
|
-
print("\n".join(xy_stats(groups, x, y, fit_stats,
|
|
1837
|
-
digits_d)))
|
|
2808
|
+
say(*scatter_report(groups, fit_stats, md_lines))
|
|
1838
2809
|
return fig
|
|
1839
2810
|
|
|
1840
2811
|
fig = plt_plotly(
|
|
1841
2812
|
disp_groups, by_name=by,
|
|
1842
2813
|
fill=fills, border=borders, shape=pt_shape, pt_size=pt_size,
|
|
2814
|
+
sizes=sizes, size_name=size_name,
|
|
1843
2815
|
x_lab=x if xlab is None else xlab,
|
|
1844
2816
|
y_lab=y if ylab is None else ylab,
|
|
1845
2817
|
ax=ax, gridT1=gridT1, gridT2=ax["axT2"],
|
|
1846
2818
|
main=main, digits_d=digits_d,
|
|
1847
|
-
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,
|
|
1848
2821
|
is_date=is_date,
|
|
1849
2822
|
pt_opacity=1 - transparency,
|
|
1850
2823
|
area_polys=area_polys,
|
|
@@ -1882,6 +2855,17 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
1882
2855
|
# runs test: connect the points and report the run analysis
|
|
1883
2856
|
# below; the run chart's center line is the median, so a run
|
|
1884
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"
|
|
1885
2869
|
run_lines = None
|
|
1886
2870
|
if show_runs:
|
|
1887
2871
|
m_run, run_lines = _run_analysis(yv, digits_d, True)
|
|
@@ -1897,7 +2881,7 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
1897
2881
|
m_cl, lbl = float(np.median(yv)), " median"
|
|
1898
2882
|
else: # "zero": line but no label
|
|
1899
2883
|
m_cl, lbl = 0.0, ""
|
|
1900
|
-
gray =
|
|
2884
|
+
gray = as_plotly_color("gray50")
|
|
1901
2885
|
fig.add_hline(y=m_cl, line=dict(color=gray, dash="dash",
|
|
1902
2886
|
width=1))
|
|
1903
2887
|
if lbl:
|
|
@@ -1911,6 +2895,8 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
1911
2895
|
|
|
1912
2896
|
if add_means: # enhance: mean crosshair
|
|
1913
2897
|
_add_means(fig, xv, yv)
|
|
2898
|
+
if qq_key is not None:
|
|
2899
|
+
_qq_reference(fig, fit_color, fit_lwd)
|
|
1914
2900
|
|
|
1915
2901
|
if scale_x is not None: # explicit axis ranges
|
|
1916
2902
|
fig.update_xaxes(range=[float(scale_x[0]),
|
|
@@ -1960,7 +2946,7 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
1960
2946
|
fig.add_trace(go.Scatter(
|
|
1961
2947
|
x=xs2, y=f2, mode="lines",
|
|
1962
2948
|
name="Fit (no outliers)",
|
|
1963
|
-
line=dict(color=
|
|
2949
|
+
line=dict(color=as_plotly_color(
|
|
1964
2950
|
get_option("fit_color", "#5C4032")
|
|
1965
2951
|
if fit_color is None else fit_color),
|
|
1966
2952
|
width=get_option("fit_lwd", 2)
|
|
@@ -1968,28 +2954,43 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
1968
2954
|
dash="dash"),
|
|
1969
2955
|
showlegend=True))
|
|
1970
2956
|
|
|
2957
|
+
# the series displays: suggestions (with the forecast report
|
|
2958
|
+
# and table when one is made), the run analysis of a run chart
|
|
2959
|
+
ser_extra = "".join(
|
|
2960
|
+
f", {k}={v!r}" if isinstance(v, str) else f", {k}={v}"
|
|
2961
|
+
for k, v in (("by", by), ("ts_unit", ts_unit),
|
|
2962
|
+
("ts_ahead", ts_ahead or None))
|
|
2963
|
+
if v is not None).replace("'", '"')
|
|
1971
2964
|
if frcst is not None:
|
|
1972
2965
|
_forecast_traces(fig, frcst, ts_PI)
|
|
1973
|
-
if
|
|
2966
|
+
if show:
|
|
1974
2967
|
for ln in frcst["report"]:
|
|
1975
2968
|
print(ln)
|
|
1976
2969
|
if ts_fitted: # observed vs model-fit table
|
|
1977
2970
|
print("\n".join(_ts_fitted_lines(
|
|
1978
2971
|
frcst, yv, x, y, ts_unit, digits_d)))
|
|
1979
|
-
|
|
2972
|
+
say(xc.suggest_series(pre, x, y, dname, given,
|
|
2973
|
+
ser_extra, pt_size=pt_size))
|
|
2974
|
+
print("Forecast\n--------")
|
|
1980
2975
|
print(frcst["forecast"].to_string(
|
|
1981
2976
|
float_format=lambda v: f"{v:.{digits_d + 2}f}"))
|
|
1982
|
-
elif
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
2977
|
+
elif is_date:
|
|
2978
|
+
say(xc.suggest_series(pre, x, y, dname, given, ser_extra,
|
|
2979
|
+
pt_size=pt_size))
|
|
2980
|
+
elif index_x: # run chart: R's run analysis
|
|
2981
|
+
if run_lines is None:
|
|
2982
|
+
_, run_lines = _run_analysis(yv, digits_d, False)
|
|
2983
|
+
say(xc.suggest_series(pre, ".Index", y, dname, given,
|
|
2984
|
+
ser_extra, run=True, pt_size=pt_size),
|
|
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
|
|
2988
|
+
else:
|
|
2989
|
+
say(*scatter_report(groups, fit_stats, md_lines))
|
|
2990
|
+
if fit_new is not None and fit in _FIT_NEW_OK and show:
|
|
1986
2991
|
print("\n".join(_fit_new_table(
|
|
1987
2992
|
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:
|
|
2993
|
+
if (jitter_x > 0 or jitter_y > 0) and show:
|
|
1993
2994
|
print("\nSome Parameter values (can be manually set)")
|
|
1994
2995
|
print("-" * 55)
|
|
1995
2996
|
print(f"size: {float(pt_size):.2f} "
|
|
@@ -2004,4 +3005,96 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
|
|
|
2004
3005
|
|
|
2005
3006
|
|
|
2006
3007
|
# font_size= scales all text of the returned figure
|
|
2007
|
-
|
|
3008
|
+
from .stats_out import attach_stats as _attach_stats # noqa: E402
|
|
3009
|
+
from .style import with_theme as _with_theme # noqa: E402
|
|
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)))
|