lessPython 0.1.0__py3-none-any.whl

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