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/plt_time.py ADDED
@@ -0,0 +1,143 @@
1
+ # plt_time.py — analog of plt.time.R
2
+ #
3
+ # ts_unit/ts_agg for XY(): aggregate a date-x time series to a
4
+ # coarser time unit, sum or mean per period. Ports .plt.time():
5
+ # infer the existing unit from the date spacing, refuse a
6
+ # requested unit finer than the data, drop the trailing
7
+ # incomplete period, aggregate per period — per by= level when
8
+ # present — with the date placed at the period start for
9
+ # months/quarters/years (weeks and days keep the last observed
10
+ # date of the period, as xts::period.apply indexes), and note
11
+ # gaps in the final series. pandas periods replace the xts/zoo
12
+ # machinery; the unit inference is the median-gap rule (the
13
+ # calendar-structure refinements of .plt.time() for series with
14
+ # missing periods are not ported).
15
+
16
+ import numpy as np
17
+ import pandas as pd
18
+
19
+ from .utils import category_order
20
+
21
+ _UNIT_ORDER = ("days", "weeks", "months", "quarters", "years")
22
+ _PERIOD_FREQ = {"weeks": "W-SUN", "months": "M",
23
+ "quarters": "Q", "years": "Y"}
24
+
25
+
26
+ def _infer_unit(dates):
27
+ """Existing time unit from the median day gap, "unknown"
28
+ when irregular. R analog: the tu_exist logic of .plt.time()"""
29
+ d = np.sort(pd.unique(pd.Series(dates).dropna()))
30
+ if len(d) <= 1:
31
+ return "days"
32
+ gaps = np.diff(d).astype("timedelta64[D]").astype(int)
33
+ gaps = gaps[gaps > 0]
34
+ if len(gaps) == 0:
35
+ return "days"
36
+ med = float(np.median(gaps))
37
+ if med <= 1.5:
38
+ return "days"
39
+ if 6 <= med <= 8: # weekly, unless biweekly
40
+ return ("unknown" if (gaps == 14).mean() >= 0.40
41
+ else "weeks")
42
+ if 28 <= med <= 31:
43
+ return "months"
44
+ if 90 <= med <= 92:
45
+ return "quarters"
46
+ if 365 <= med <= 366:
47
+ return "years"
48
+ return "unknown"
49
+
50
+
51
+ def _agg_one(dser, yser, ts_unit, aggfun):
52
+ """Aggregate one series: truncate the trailing incomplete
53
+ period, then one value per period. R analogs: ts_truncate()
54
+ and the xts::period.apply block of .plt.time()"""
55
+ if ts_unit == "days": # collapse duplicate dates
56
+ per = dser
57
+ else:
58
+ per = dser.dt.to_period(_PERIOD_FREQ[ts_unit])
59
+ # trailing partial period: last date short of period end
60
+ last_per = per.iloc[int(np.argmax(dser.to_numpy()))]
61
+ if dser.max() < last_per.end_time.normalize():
62
+ keep = (per != last_per).to_numpy()
63
+ dser, yser, per = dser[keep], yser[keep], per[keep]
64
+ if len(dser) == 0:
65
+ return (np.array([], dtype="datetime64[ns]"),
66
+ np.array([]))
67
+ y_out = yser.groupby(per.to_numpy()).agg(
68
+ lambda s: aggfun(s.to_numpy())) # NaN propagates, as R
69
+ if ts_unit in ("months", "quarters", "years"):
70
+ x_out = y_out.index.to_timestamp(how="start")
71
+ else: # last observed date
72
+ x_out = dser.groupby(per.to_numpy()).max().to_numpy()
73
+ return np.asarray(x_out), y_out.to_numpy()
74
+
75
+
76
+ def plt_time(x_ser, y_ser, by_ser, ts_unit, ts_agg, quiet=True):
77
+ """Aggregate the time series to ts_unit by ts_agg. Returns
78
+ (x_ser, y_ser, by_ser, ts_unit). R analog: .plt.time()"""
79
+ hold7 = ts_unit == "days7"
80
+ if hold7:
81
+ ts_unit = "days"
82
+ if ts_unit not in _UNIT_ORDER:
83
+ raise ValueError(
84
+ "ts_unit must be one of days, days7, weeks, months, "
85
+ "quarters, years")
86
+
87
+ unit_exist = _infer_unit(
88
+ x_ser.to_numpy() if by_ser is None
89
+ else x_ser[by_ser == category_order(by_ser)[0]]
90
+ .to_numpy())
91
+ if (unit_exist != "unknown"
92
+ and _UNIT_ORDER.index(ts_unit)
93
+ < _UNIT_ORDER.index(unit_exist)):
94
+ raise ValueError(
95
+ f"Resolution of data is {unit_exist}; requested "
96
+ f"{ts_unit} data is not available")
97
+
98
+ aggfun = np.sum if ts_agg == "sum" else np.mean
99
+ if by_ser is None:
100
+ xo, yo = _agg_one(x_ser, y_ser, ts_unit, aggfun)
101
+ x_out = pd.Series(xo, name=x_ser.name)
102
+ y_out = pd.Series(yo, name=y_ser.name)
103
+ by_out = None
104
+ else:
105
+ levels = category_order(by_ser)
106
+ xs, ys, bs = [], [], []
107
+ for lvl in levels:
108
+ m = (by_ser == lvl).to_numpy()
109
+ xl, yl = _agg_one(x_ser[m], y_ser[m], ts_unit,
110
+ aggfun)
111
+ xs.append(xl)
112
+ ys.append(yl)
113
+ bs.extend([lvl] * len(xl))
114
+ x_out = pd.Series(np.concatenate(xs), name=x_ser.name)
115
+ y_out = pd.Series(np.concatenate(ys), name=y_ser.name)
116
+ by_out = pd.Series(
117
+ pd.Categorical(bs, categories=levels),
118
+ name=by_ser.name)
119
+
120
+ # gaps in the aggregated series (first by level, as R)
121
+ d = np.sort(x_out[by_out == levels[0]].to_numpy()
122
+ if by_out is not None else x_out.to_numpy())
123
+ gaps = False
124
+ if len(d) > 1:
125
+ dd = pd.DatetimeIndex(d)
126
+ if ts_unit == "days":
127
+ gaps = (np.diff(d).astype("timedelta64[D]")
128
+ .astype(int) > 1).any()
129
+ elif ts_unit == "weeks":
130
+ gaps = (np.diff(d).astype("timedelta64[D]")
131
+ .astype(int) > 7).any()
132
+ elif ts_unit == "months":
133
+ gaps = (np.diff(dd.year * 12 + dd.month) > 1).any()
134
+ elif ts_unit == "quarters":
135
+ gaps = (np.diff(dd.year * 4 + dd.quarter) > 1).any()
136
+ elif ts_unit == "years":
137
+ gaps = (np.diff(dd.year) > 1).any()
138
+ if gaps and not quiet:
139
+ print("There are gaps in the dates, so that there are "
140
+ "not regular\nintervals between all the dates.")
141
+
142
+ return (x_out, y_out, by_out,
143
+ "days7" if hold7 else ts_unit)
lessPy/prob_norm.py ADDED
@@ -0,0 +1,111 @@
1
+ # prob_norm.py — analog of prob_norm.R.
2
+ #
3
+ # prob_norm(): plot a normal density curve, shade the interval
4
+ # from lo to hi, and return the probability P(lo < Y < hi). An
5
+ # open bound (lo or hi = None) extends to the tail. Prints the
6
+ # probability and returns a results object with the plotly figure
7
+ # in .plots.
8
+
9
+ import numpy as np
10
+
11
+ from .utils import fmt
12
+
13
+ _FILL_NRM = "#E8E8E8" # grey91
14
+ _FILL_INT = "#9FB6CD" # slategray3
15
+
16
+
17
+ class ProbNormResults:
18
+ """Numeric results of prob_norm(): the interval probability,
19
+ the bounds lo/hi, mu/sigma, and the plotly figure in .plots
20
+ ("norm")."""
21
+
22
+ def __init__(self, **kw):
23
+ self.__dict__.update(kw)
24
+
25
+ def __repr__(self):
26
+ return f"<lessPy prob_norm: P={self.prob:.4g}>"
27
+
28
+
29
+ def prob_norm(lo=None, hi=None, mu=0, sigma=1, nrm_color="black",
30
+ fill_nrm=_FILL_NRM, fill_int=_FILL_INT, y_axis=False,
31
+ z=True):
32
+ """Probability of a normal interval: shade the area under the
33
+ N(mu, sigma) curve from lo to hi and return P(lo < Y < hi). A
34
+ None bound extends to the tail. z adds a second axis of z
35
+ scores (suppressed for the standard normal). Prints the
36
+ probability and returns a ProbNormResults with .plots.
37
+ R analog: prob_norm()"""
38
+ from scipy.stats import norm
39
+ if lo is not None and hi is not None and lo > hi:
40
+ raise ValueError(f"lo ({lo}) cannot be larger than hi "
41
+ f"({hi})")
42
+ if sigma <= 0:
43
+ raise ValueError("sigma must be larger than zero")
44
+
45
+ if mu == 0 and sigma == 1:
46
+ z = False
47
+
48
+ lo_lbl = "..." if lo is None else str(lo)
49
+ hi_lbl = "..." if hi is None else str(hi)
50
+ lo_v = mu - sigma * 10 if lo is None else lo
51
+ hi_v = mu + sigma * 10 if hi is None else hi
52
+
53
+ prob = norm.cdf(hi_v, mu, sigma) - norm.cdf(lo_v, mu, sigma)
54
+
55
+ fig = _plot(lo_v, hi_v, lo_lbl, hi_lbl, mu, sigma, prob,
56
+ nrm_color, fill_nrm, fill_int, y_axis, z)
57
+
58
+ print(f"Probability: {prob}")
59
+
60
+ return ProbNormResults(prob=prob, lo=lo_v, hi=hi_v, mu=mu,
61
+ sigma=sigma, plots={"norm": fig})
62
+
63
+
64
+ def _plot(lo, hi, lo_lbl, hi_lbl, mu, sigma, prob, nrm_color,
65
+ fill_nrm, fill_int, y_axis, z):
66
+ import plotly.graph_objects as go
67
+ from scipy.stats import norm
68
+ min_x, max_x = mu - 4 * sigma, mu + 4 * sigma
69
+ cuts = [mu + k * sigma for k in range(-4, 5)]
70
+ x = np.linspace(min_x, max_x, 200)
71
+ dnrm = norm.pdf(x, mu, sigma)
72
+
73
+ fig = go.Figure()
74
+ # full curve, filled
75
+ fig.add_trace(go.Scatter(
76
+ x=np.concatenate(([min_x], x, [max_x])),
77
+ y=np.concatenate(([0.0], dnrm, [0.0])),
78
+ mode="lines", fill="toself", fillcolor=fill_nrm,
79
+ line=dict(color=nrm_color), hoverinfo="skip",
80
+ showlegend=False))
81
+ # shaded interval
82
+ m = (x > lo) & (x < hi)
83
+ xs = np.concatenate(([lo], x[m], [hi]))
84
+ ys = np.concatenate(([0.0], dnrm[m],
85
+ [0.0]))
86
+ fig.add_trace(go.Scatter(
87
+ x=xs, y=ys, mode="lines", fill="toself",
88
+ fillcolor=fill_int, line=dict(color=fill_int),
89
+ hoverinfo="skip", showlegend=False))
90
+
91
+ title = (f"Prob = {float(f'{prob:.4g}')} for Y from "
92
+ f"{lo_lbl} to {hi_lbl}<br>"
93
+ f"<sub>μ={mu} σ={sigma}</sub>")
94
+ fig.update_layout(
95
+ plot_bgcolor="white", paper_bgcolor="white",
96
+ margin=dict(t=50, r=20, b=60 if z else 40, l=40),
97
+ title=dict(text=title, x=0.5, xanchor="center",
98
+ font=dict(size=13)),
99
+ xaxis=dict(tickvals=cuts, showgrid=False,
100
+ linecolor="black", ticks="outside"),
101
+ yaxis=dict(visible=y_axis, showgrid=False,
102
+ title="Normal Density" if y_axis else None,
103
+ rangemode="tozero"),
104
+ showlegend=False)
105
+ if z: # second row: z scores
106
+ for k, cx in zip(range(-4, 5), cuts):
107
+ fig.add_annotation(x=cx, xref="x", y=-0.13,
108
+ yref="paper", text=str(k),
109
+ showarrow=False,
110
+ font=dict(size=11))
111
+ return fig
lessPy/prob_tcut.py ADDED
@@ -0,0 +1,131 @@
1
+ # prob_tcut.py — analog of prob_tcut.R.
2
+ #
3
+ # prob_tcut(): plot a t density (df degrees of freedom) with the
4
+ # central 1-alpha region shaded and the two alpha/2 tails shaded,
5
+ # overlaid with a standard normal for comparison. Both the t and
6
+ # normal two-tailed critical cutoffs are drawn as vertical lines
7
+ # and labeled. Prints and returns the upper t cutoff,
8
+ # qt(1 - alpha/2, df) — the value R reports (labeled "Probability").
9
+
10
+ import numpy as np
11
+
12
+ from .utils import fmt
13
+
14
+ _BG = "#F9F9FC" # rgb(249,249,252)
15
+ _TAIL = "#8B475D" # palevioletred4
16
+ _NRM = "#B3B3B3" # gray(.7)
17
+ _T = "#141414" # gray(.08)
18
+
19
+
20
+ class ProbTcutResults:
21
+ """Result of prob_tcut(): the upper t cutoff, df, alpha, and the
22
+ plotly figure in .plots ("tcut")."""
23
+
24
+ def __init__(self, **kw):
25
+ self.__dict__.update(kw)
26
+
27
+ def __repr__(self):
28
+ return f"<lessPy prob_tcut: df={self.df} cutoff={self.cutoff:.4g}>"
29
+
30
+
31
+ def prob_tcut(df, alpha=0.05, digits_d=3, y_axis=False,
32
+ fill="aliceblue", color_tail=_TAIL, nrm_color=_NRM,
33
+ color_t=_T):
34
+ """Two-tailed t cutoffs at alpha: shade the central 1-alpha
35
+ region of the t(df) curve and its alpha/2 tails, overlay a
36
+ standard normal, and mark both sets of critical values. Returns
37
+ a ProbTcutResults whose cutoff is the upper t critical value
38
+ qt(1 - alpha/2, df). R analog: prob_tcut()"""
39
+ from scipy.stats import t as t_dist, norm
40
+ if df < 2:
41
+ raise ValueError("df must be 2 or larger")
42
+
43
+ tail = alpha / 2
44
+ t_lo = t_dist.ppf(tail, df)
45
+ t_hi = t_dist.ppf(1 - tail, df)
46
+ cutoff = t_hi # value R returns
47
+ n_lo = norm.ppf(tail)
48
+ n_hi = norm.ppf(1 - tail)
49
+
50
+ fig = _plot(df, alpha, t_lo, t_hi, n_lo, n_hi, digits_d, y_axis,
51
+ fill, color_tail, nrm_color, color_t)
52
+
53
+ print(f"Probability: {cutoff}")
54
+ return ProbTcutResults(cutoff=cutoff, df=df, alpha=alpha,
55
+ plots={"tcut": fig})
56
+
57
+
58
+ def _cut_line(fig, x, top, color):
59
+ fig.add_shape(type="line", x0=x, x1=x, y0=0, y1=top,
60
+ line=dict(color=color))
61
+
62
+
63
+ def _cut_lbl(fig, x, y, value, digits_d, color):
64
+ fig.add_annotation(x=x, y=y, text=fmt(value, digits_d),
65
+ showarrow=False, font=dict(size=12,
66
+ color=color))
67
+
68
+
69
+ def _plot(df, alpha, t_lo, t_hi, n_lo, n_hi, digits_d, y_axis,
70
+ fill, color_tail, nrm_color, color_t):
71
+ import plotly.graph_objects as go
72
+ from scipy.stats import t as t_dist, norm
73
+ xmin, xmax = -5.5, 5.5
74
+ x = np.linspace(xmin, xmax, 400)
75
+ yt = t_dist.pdf(x, df)
76
+
77
+ fig = go.Figure()
78
+ # central 1-alpha region of the t curve (aliceblue)
79
+ fig.add_trace(go.Scatter(
80
+ x=np.concatenate(([xmin], x, [xmax])),
81
+ y=np.concatenate(([0.0], yt, [0.0])),
82
+ mode="lines", fill="toself", fillcolor=fill,
83
+ line=dict(color="rgba(0,0,0,0)"), hoverinfo="skip",
84
+ showlegend=False))
85
+ # alpha/2 tails (palevioletred4)
86
+ for lo, hi in ((xmin, t_lo), (t_hi, xmax)):
87
+ xs = np.linspace(lo, hi, 100)
88
+ ys = t_dist.pdf(xs, df)
89
+ fig.add_trace(go.Scatter(
90
+ x=np.concatenate(([lo], xs, [hi])),
91
+ y=np.concatenate(([0.0], ys, [0.0])),
92
+ mode="lines", fill="toself", fillcolor=color_tail,
93
+ line=dict(color=nrm_color, width=1), hoverinfo="skip",
94
+ showlegend=False))
95
+ # t curve on top
96
+ fig.add_trace(go.Scatter(
97
+ x=x, y=yt, mode="lines", line=dict(color=color_t, width=3),
98
+ name=f"t, df={df}", hoverinfo="skip"))
99
+ # standard normal overlay
100
+ fig.add_trace(go.Scatter(
101
+ x=x, y=norm.pdf(x), mode="lines",
102
+ line=dict(color=nrm_color, width=2), name="Normal",
103
+ hoverinfo="skip"))
104
+
105
+ # t cutoffs: line to .19, label at .20
106
+ for c in (t_lo, t_hi):
107
+ _cut_line(fig, c, 0.19, color_tail)
108
+ _cut_lbl(fig, c, 0.205, c, digits_d, color_t)
109
+ # normal cutoffs: line to .25, label at .26
110
+ for c in (n_lo, n_hi):
111
+ _cut_line(fig, c, 0.25, nrm_color)
112
+ _cut_lbl(fig, c, 0.265, c, digits_d, nrm_color)
113
+ # central confidence label, e.g. "95%"
114
+ fig.add_annotation(x=0, y=0.175,
115
+ text=f"{round(100 * (1 - alpha))}%",
116
+ showarrow=False,
117
+ font=dict(size=17, color=nrm_color))
118
+
119
+ fig.update_layout(
120
+ plot_bgcolor=_BG, paper_bgcolor="white",
121
+ margin=dict(t=20, r=20, b=50, l=40),
122
+ xaxis=dict(title="Standard Errors from Zero", range=[xmin,
123
+ xmax], showgrid=False, zeroline=False,
124
+ linecolor="black", ticks="outside"),
125
+ yaxis=dict(visible=y_axis, range=[0, 0.42], showgrid=False,
126
+ zeroline=False,
127
+ title="Density" if y_axis else None),
128
+ legend=dict(x=0.99, y=0.99, xanchor="right", yanchor="top",
129
+ bordercolor="black", borderwidth=0.5,
130
+ font=dict(size=10)))
131
+ return fig
lessPy/prob_znorm.py ADDED
@@ -0,0 +1,110 @@
1
+ # prob_znorm.py — analog of prob_znorm.R.
2
+ #
3
+ # prob_znorm(): plot a normal density curve marked at each integer
4
+ # standard deviation, with the central +/-1, +/-2, and +/-3 sigma
5
+ # regions shaded by three nested translucent bands. Because the
6
+ # bands are translucent and stacked, the center (covered by all
7
+ # three) is darkest, then two layers, then one — the empirical-rule
8
+ # graphic. Unlike prob_norm() this returns no probability; it is a
9
+ # display, so the results object holds only the plotly figure in
10
+ # .plots ("znorm").
11
+
12
+ import numpy as np
13
+
14
+
15
+ class ProbZnormResults:
16
+ """Result of prob_znorm(): mu/sigma and the plotly figure in
17
+ .plots ("znorm"). No probability is computed."""
18
+
19
+ def __init__(self, **kw):
20
+ self.__dict__.update(kw)
21
+
22
+ def __repr__(self):
23
+ return f"<lessPy prob_znorm: mu={self.mu} sigma={self.sigma}>"
24
+
25
+
26
+ def prob_znorm(mu=0, sigma=1, color_border="gray10", r=0.10,
27
+ g=0.34, b=0.94, a=0.20, main="", y_axis=False,
28
+ z=True):
29
+ """Normal curve with the central 1/2/3-sigma regions shown as
30
+ three nested translucent bands (the empirical-rule graphic).
31
+ color_border is the curve/outline color; r/g/b/a (each in
32
+ [0, 1]) set the translucent fill. z adds a second axis of z
33
+ scores (suppressed for the standard normal). Returns a
34
+ ProbZnormResults with .plots. R analog: prob_znorm()"""
35
+ if not all(0 <= v <= 1 for v in (r, g, b, a)):
36
+ raise ValueError("r, g, b and a must each be between 0 and "
37
+ "1, inclusive")
38
+ if sigma <= 0:
39
+ raise ValueError("sigma must be larger than zero")
40
+
41
+ if mu == 0 and sigma == 1:
42
+ z = False
43
+
44
+ border = _gray_to_hex(color_border)
45
+ fill = f"rgba({round(r * 255)},{round(g * 255)}," \
46
+ f"{round(b * 255)},{a})"
47
+
48
+ fig = _plot(mu, sigma, border, fill, main, y_axis, z)
49
+ return ProbZnormResults(mu=mu, sigma=sigma, plots={"znorm": fig})
50
+
51
+
52
+ def _gray_to_hex(color):
53
+ """plotly rejects R's grayNN/greyNN names; convert those to hex,
54
+ pass any other color string through unchanged."""
55
+ lc = color.lower()
56
+ for pre in ("gray", "grey"):
57
+ if lc.startswith(pre) and lc[len(pre):].isdigit():
58
+ v = round(int(lc[len(pre):]) / 100 * 255)
59
+ return f"#{v:02X}{v:02X}{v:02X}"
60
+ return color
61
+
62
+
63
+ def _plot(mu, sigma, border, fill, main, y_axis, z):
64
+ import plotly.graph_objects as go
65
+ from scipy.stats import norm
66
+ xmin, xmax = mu - 4 * sigma, mu + 4 * sigma
67
+ cuts = [mu + k * sigma for k in range(-4, 5)]
68
+ x = np.linspace(xmin, xmax, 200)
69
+ y = norm.pdf(x, mu, sigma)
70
+
71
+ fig = go.Figure()
72
+ # normal curve
73
+ fig.add_trace(go.Scatter(
74
+ x=x, y=y, mode="lines", line=dict(color=border, width=2),
75
+ hoverinfo="skip", showlegend=False))
76
+ # dotted vertical segment at the mean, up to the peak
77
+ fig.add_shape(type="line", x0=mu, x1=mu, y0=0,
78
+ y1=norm.pdf(mu, mu, sigma),
79
+ line=dict(color=border, dash="dot"))
80
+ # nested translucent bands: +/-3, +/-2, +/-1 sigma. Drawn in
81
+ # this order so the overlaps stack — the center is darkest.
82
+ for k in (3, 2, 1):
83
+ lo, hi = mu - k * sigma, mu + k * sigma
84
+ m = (x > lo) & (x < hi)
85
+ fig.add_trace(go.Scatter(
86
+ x=np.concatenate(([lo], x[m], [hi])),
87
+ y=np.concatenate(([0.0], y[m], [0.0])),
88
+ mode="lines", fill="toself", fillcolor=fill,
89
+ line=dict(color=border, dash="dot", width=1),
90
+ hoverinfo="skip", showlegend=False))
91
+
92
+ fig.update_layout(
93
+ plot_bgcolor="white", paper_bgcolor="white",
94
+ margin=dict(t=50 if main else 30, r=20,
95
+ b=60 if z else 40, l=40),
96
+ title=dict(text=main, x=0.5, xanchor="center",
97
+ font=dict(size=13)),
98
+ xaxis=dict(tickvals=cuts, showgrid=False,
99
+ linecolor="black", ticks="outside"),
100
+ yaxis=dict(visible=y_axis, showgrid=False,
101
+ title="Normal Density" if y_axis else None,
102
+ rangemode="tozero"),
103
+ showlegend=False)
104
+ if z: # second row: z scores
105
+ for k, cx in zip(range(-4, 5), cuts):
106
+ fig.add_annotation(x=cx, xref="x", y=-0.13,
107
+ yref="paper", text=str(k),
108
+ showarrow=False,
109
+ font=dict(size=11))
110
+ return fig