rcas 0.2.0

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 (117) hide show
  1. checksums.yaml +7 -0
  2. data/CITATION.cff +17 -0
  3. data/DESIGN.md +783 -0
  4. data/LICENSE +21 -0
  5. data/MANUAL.md +6265 -0
  6. data/README.md +267 -0
  7. data/bin/rcas +9 -0
  8. data/bin/rcas-app +9 -0
  9. data/bin/rcas-chat +9 -0
  10. data/lib/rcas/algebraic.rb +481 -0
  11. data/lib/rcas/analysis.rb +966 -0
  12. data/lib/rcas/app/launcher.rb +203 -0
  13. data/lib/rcas/app/public/app.css +402 -0
  14. data/lib/rcas/app/public/app.js +449 -0
  15. data/lib/rcas/app/public/index.html +46 -0
  16. data/lib/rcas/app/server.rb +220 -0
  17. data/lib/rcas/app/window.rb +94 -0
  18. data/lib/rcas/app/worksheet.rb +290 -0
  19. data/lib/rcas/app.rb +168 -0
  20. data/lib/rcas/background.rb +758 -0
  21. data/lib/rcas/chat/assistant.rb +199 -0
  22. data/lib/rcas/chat/picker.rb +164 -0
  23. data/lib/rcas/chat/repl.rb +583 -0
  24. data/lib/rcas/chat/session.rb +137 -0
  25. data/lib/rcas/chat/settings.rb +71 -0
  26. data/lib/rcas/chat/style.rb +30 -0
  27. data/lib/rcas/chat/tool.rb +53 -0
  28. data/lib/rcas/chat/ui.rb +316 -0
  29. data/lib/rcas/chat/usage.rb +62 -0
  30. data/lib/rcas/chat/workspace.rb +132 -0
  31. data/lib/rcas/chat.rb +54 -0
  32. data/lib/rcas/coefficients.rb +170 -0
  33. data/lib/rcas/combinatorics.rb +274 -0
  34. data/lib/rcas/complex_parts.rb +160 -0
  35. data/lib/rcas/constants.rb +129 -0
  36. data/lib/rcas/core_ext.rb +35 -0
  37. data/lib/rcas/decide.rb +501 -0
  38. data/lib/rcas/decompositions.rb +241 -0
  39. data/lib/rcas/differentiate.rb +144 -0
  40. data/lib/rcas/discussion.rb +558 -0
  41. data/lib/rcas/distributions.rb +980 -0
  42. data/lib/rcas/dixon.rb +95 -0
  43. data/lib/rcas/docs.rb +321 -0
  44. data/lib/rcas/domains.rb +728 -0
  45. data/lib/rcas/expand.rb +174 -0
  46. data/lib/rcas/expression.rb +613 -0
  47. data/lib/rcas/factor.rb +605 -0
  48. data/lib/rcas/finite_field.rb +577 -0
  49. data/lib/rcas/fourier.rb +118 -0
  50. data/lib/rcas/fps.rb +678 -0
  51. data/lib/rcas/fraction.rb +126 -0
  52. data/lib/rcas/functions.rb +1136 -0
  53. data/lib/rcas/gcd.rb +112 -0
  54. data/lib/rcas/geometry.rb +266 -0
  55. data/lib/rcas/groebner.rb +162 -0
  56. data/lib/rcas/hold.rb +277 -0
  57. data/lib/rcas/hypothesis.rb +364 -0
  58. data/lib/rcas/inequalities.rb +689 -0
  59. data/lib/rcas/integral_functions.rb +260 -0
  60. data/lib/rcas/integrate.rb +1589 -0
  61. data/lib/rcas/integrate_substitutions.rb +434 -0
  62. data/lib/rcas/interpolate.rb +40 -0
  63. data/lib/rcas/irb.rb +146 -0
  64. data/lib/rcas/laplace.rb +159 -0
  65. data/lib/rcas/latex.rb +556 -0
  66. data/lib/rcas/lattice.rb +172 -0
  67. data/lib/rcas/linear_algebra.rb +117 -0
  68. data/lib/rcas/linear_program.rb +416 -0
  69. data/lib/rcas/lint.rb +79 -0
  70. data/lib/rcas/matrix.rb +531 -0
  71. data/lib/rcas/matrix_multiply.rb +202 -0
  72. data/lib/rcas/multimodular.rb +286 -0
  73. data/lib/rcas/named_polynomials.rb +274 -0
  74. data/lib/rcas/number_theory.rb +443 -0
  75. data/lib/rcas/numerics.rb +825 -0
  76. data/lib/rcas/ode.rb +488 -0
  77. data/lib/rcas/openmath/objects.rb +364 -0
  78. data/lib/rcas/openmath/phrasebook.rb +551 -0
  79. data/lib/rcas/openmath/popcorn.rb +518 -0
  80. data/lib/rcas/openmath/xml.rb +309 -0
  81. data/lib/rcas/openmath.rb +49 -0
  82. data/lib/rcas/petkovsek.rb +165 -0
  83. data/lib/rcas/piecewise.rb +488 -0
  84. data/lib/rcas/plot.rb +763 -0
  85. data/lib/rcas/plot3d.rb +419 -0
  86. data/lib/rcas/poly_matrix.rb +318 -0
  87. data/lib/rcas/poly_recurrence.rb +117 -0
  88. data/lib/rcas/polynomial.rb +466 -0
  89. data/lib/rcas/precision.rb +925 -0
  90. data/lib/rcas/printer.rb +150 -0
  91. data/lib/rcas/product.rb +155 -0
  92. data/lib/rcas/q_difference.rb +296 -0
  93. data/lib/rcas/q_functions.rb +158 -0
  94. data/lib/rcas/q_summation.rb +308 -0
  95. data/lib/rcas/q_zeilberger.rb +199 -0
  96. data/lib/rcas/random.rb +506 -0
  97. data/lib/rcas/rational_function.rb +186 -0
  98. data/lib/rcas/recurrence.rb +323 -0
  99. data/lib/rcas/render.rb +431 -0
  100. data/lib/rcas/results.rb +192 -0
  101. data/lib/rcas/scalar.rb +219 -0
  102. data/lib/rcas/series.rb +726 -0
  103. data/lib/rcas/simplify.rb +649 -0
  104. data/lib/rcas/solve.rb +2002 -0
  105. data/lib/rcas/special.rb +163 -0
  106. data/lib/rcas/statistics.rb +175 -0
  107. data/lib/rcas/steps.rb +835 -0
  108. data/lib/rcas/summation.rb +532 -0
  109. data/lib/rcas/trig.rb +264 -0
  110. data/lib/rcas/van_hoeij.rb +241 -0
  111. data/lib/rcas/vector.rb +175 -0
  112. data/lib/rcas/vector_calculus.rb +411 -0
  113. data/lib/rcas/version.rb +5 -0
  114. data/lib/rcas/zeilberger.rb +358 -0
  115. data/lib/rcas.rb +91 -0
  116. data/package.json +8 -0
  117. metadata +206 -0
data/lib/rcas/hold.rb ADDED
@@ -0,0 +1,277 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RCAS
4
+ # Ruby keeps the source of eval'd code (and so lets us read a block's
5
+ # syntax tree) only while this is on. irb turns it on itself; other hosts
6
+ # such as the chat REPL evaluate input with eval, so we turn it on here.
7
+ RubyVM.keep_script_lines = true
8
+
9
+ # hold { 1 + 2 } builds the expression tree of the block's source instead of
10
+ # letting Ruby evaluate it, so numeric arithmetic is kept symbolic:
11
+ #
12
+ # hold { 1 + 2 } # => 1 + 2
13
+ # hold { 1 / 2 }.simplify # => 1/2 (Ruby alone would give 0)
14
+ # hold { 2 * x**2 / 2 } # => 2*x**2/2
15
+ #
16
+ # Inside the block: literals become Num, bare identifiers and symbols
17
+ # become Var, the arithmetic operators build nodes, sin/cos/... build Fn,
18
+ # local variables and constants are read from the block's binding, and any
19
+ # other method call is performed normally on the held arguments.
20
+ module Hold
21
+ OPERATORS = { :+ => Add, :- => Sub, :* => Mul, :/ => Div, :** => Pow }.freeze
22
+ LITERALS = %i[LIT INTEGER FLOAT RATIONAL IMAGINARY SYM].freeze
23
+ # Calls kept as formal nodes instead of being evaluated; see Expression#evaluate.
24
+ FORMAL = %i[integrate diff sum product limit discuss].freeze
25
+
26
+ module_function
27
+
28
+ IDENTIFIER = RCAS::IDENTIFIER
29
+
30
+ # The syntax tree of a piece of source text, without Ruby's lint
31
+ # warnings. Parsing `x + 1` on its own warns "possibly useless use of +
32
+ # in void context" under -w, and so does every line of a session that is
33
+ # read to be inspected (In[n], the chat's checks) rather than run: the
34
+ # warnings are about the fragment, not about anything the reader wrote
35
+ # wrong, and the test suite printed dozens of them.
36
+ def parse(text) = quietly { RubyVM::AbstractSyntaxTree.parse(text) }
37
+
38
+ # AbstractSyntaxTree.of parses the block's lines again, with the same
39
+ # warnings.
40
+ def quietly
41
+ verbose = $VERBOSE
42
+ $VERBOSE = nil
43
+ yield
44
+ ensure
45
+ $VERBOSE = verbose
46
+ end
47
+
48
+ # A block whose source cannot be read is refused, never run: running it
49
+ # is exactly what hold was asked not to do, and `hold { 1 / 2 }` came
50
+ # back as 0 under Ruby 4 that way (a review, 23 Sept 2026).
51
+ def hold(block)
52
+ body = block_body(block)
53
+ raise RCAS::Unsupported, "hold could not read the block's source, so it cannot keep it unevaluated" if body.nil?
54
+ Builder.new(block.binding).build(body)
55
+ end
56
+
57
+ # The syntax tree of the block's body. RubyVM::AbstractSyntaxTree.of
58
+ # reads it directly where the block was compiled by parse.y (Ruby 3.3,
59
+ # or --parser=parse.y later, which hands back the whole call, ITER,
60
+ # rather than the block's SCOPE). A block compiled by Prism - the
61
+ # default since Ruby 3.4 - has no such tree, and `reparsed_body` cuts
62
+ # its source out by the code location of its instruction sequence.
63
+ def block_body(block)
64
+ ast = quietly { RubyVM::AbstractSyntaxTree.of(block, keep_script_lines: true) }
65
+ ast = ast.children.last if ast&.type == :ITER
66
+ ast&.children&.last
67
+ rescue ArgumentError, RuntimeError, IOError, SystemCallError
68
+ reparsed_body(block)
69
+ end
70
+
71
+ def reparsed_body(block)
72
+ iseq = RubyVM::InstructionSequence.of(block)
73
+ l1, c1, l2, c2 = iseq.to_a[4][:code_location]
74
+ eval_lines = iseq.script_lines
75
+ lines = eval_lines || (File.readlines(iseq.path) if File.file?(iseq.path))
76
+ return nil unless lines && l1
77
+ # a file starts at line 1; code eval'd with a line number (irb's, the
78
+ # chat's) starts wherever that says, which the block does not record,
79
+ # so every start that keeps the block inside the text is tried
80
+ bases = eval_lines ? [1, *(l2 - lines.size + 1..l1)].uniq : [1]
81
+ bases.each do |base|
82
+ from, to = l1 - base, l2 - base
83
+ next if from.negative? || to >= lines.size
84
+ text = from == to ? lines[from].byteslice(c1...c2) : lines[from].byteslice(c1..) + lines[from + 1...to].join + lines[to].byteslice(0, c2)
85
+ next unless text&.match?(/\A(\{|do\b)/)
86
+ node = begin
87
+ Hold.parse("proc #{text}").children.last
88
+ rescue SyntaxError
89
+ next
90
+ end
91
+ return node.children.last.children.last if node&.type == :ITER
92
+ end
93
+ nil
94
+ rescue ArgumentError, RuntimeError, IOError, SystemCallError
95
+ nil
96
+ end
97
+
98
+ # The same for a line of source text instead of a block: the expression
99
+ # the line builds, held. +context+ is the binding its names are read in.
100
+ # nil for an empty line; raises SyntaxError on one that does not parse
101
+ # and ArgumentError on a node hold cannot keep. RCAS::Results uses it
102
+ # for In[n].
103
+ def source(text, context = nil)
104
+ body = parse(text).children.last
105
+ body = body.children.first if body&.type == :BEGIN # an empty line, or a comment
106
+ body.nil? ? nil : Builder.new(context || TOPLEVEL_BINDING).build(body)
107
+ end
108
+
109
+ class Builder
110
+ def initialize(binding)
111
+ @binding = binding
112
+ end
113
+
114
+ def build(node)
115
+ case node.type
116
+ when *LITERALS then literal(node.children.first)
117
+ when :OPCALL then operator(node)
118
+ when :LVAR, :DVAR then lift(@binding.local_variable_get(node.children.first))
119
+ when :VCALL then identifier(node.children.first)
120
+ when :FCALL then function(node.children[0], arguments(node.children[1]))
121
+ when :CALL then call(build(node.children[0]), node.children[1], arguments(node.children[2]))
122
+ when :BLOCK then node.children.map { |c| build(c) }.last
123
+ when :BEGIN then build(node.children.first)
124
+ when :HASH then hash_node(node)
125
+ when :DOT2, :DOT3 then range_node(node)
126
+ when :CONST, :COLON2, :COLON3 then lift(@binding.eval(constant_path(node)))
127
+ when :SELF then lift(@binding.receiver)
128
+ else evaluate(node)
129
+ end
130
+ end
131
+
132
+ private
133
+
134
+ # Anything else (a block call, a string, an array, ...) is evaluated as
135
+ # written and the value is used.
136
+ def evaluate(node)
137
+ source = begin
138
+ node.source
139
+ rescue StandardError # user code: a NameError is an answer here
140
+ nil
141
+ end
142
+ raise ArgumentError, "hold: can't keep a #{node.type} node (line #{node.first_lineno})" if source.nil?
143
+ lift(@binding.eval(source))
144
+ end
145
+
146
+ def literal(value)
147
+ case value
148
+ when Symbol then Var.new(value)
149
+ when Numeric then Num.new(value)
150
+ else raise ArgumentError, "hold: unsupported literal #{value.inspect}"
151
+ end
152
+ end
153
+
154
+ def operator(node)
155
+ receiver, op, list = node.children
156
+ left = build(receiver)
157
+ if list.nil?
158
+ return Neg.new(left) if op == :-@
159
+ return left if op == :+@
160
+ raise ArgumentError, "hold: unsupported unary operator #{op}"
161
+ end
162
+ right = build(list.children.first)
163
+ return Equation.new(left, right) if op == :== # hold { x**2 - 3 == 0 } is the equation
164
+ return Inequality.new(left, :!=, right) if op == :!=
165
+ klass = OPERATORS[op]
166
+ klass ? klass.new(left, right) : lift(left.public_send(op, right))
167
+ end
168
+
169
+ # A bare name: a real method of the block's self if there is one
170
+ # (irb's auto-symbols included), otherwise a variable.
171
+ # A bare name: a local of the block's binding when one is set (irb
172
+ # re-parses each line on its own, so earlier locals show up as bare
173
+ # names), else a real method of the block's self (irb's auto-symbols
174
+ # included), else a variable. A hoisted-but-unassigned local is nil
175
+ # and is skipped.
176
+ def identifier(name)
177
+ if @binding.local_variable_defined?(name) && !(value = @binding.local_variable_get(name)).nil?
178
+ return lift(value)
179
+ end
180
+ value = @binding.receiver.__send__(name)
181
+ value.nil? ? Var.new(name) : lift(value) # Kernel#p without arguments returns nil: an indeterminate
182
+ rescue NameError
183
+ Var.new(name)
184
+ end
185
+
186
+ def function(name, args)
187
+ return Pow.new(args.first, Num.new(Rational(1, 2))) if name == :sqrt && args.size == 1
188
+ return Fn.new(name, args) if Functions::NAMES.include?(name)
189
+ return formal(name, args) if FORMAL.include?(name)
190
+ receiver = @binding.receiver
191
+ target = receiver.respond_to?(name, true) ? receiver : RCAS
192
+ lift(target.__send__(name, *args))
193
+ end
194
+
195
+ def call(receiver, name, args)
196
+ klass = OPERATORS[name]
197
+ return klass.new(receiver, args.first) if klass && args.size == 1
198
+ return Neg.new(receiver) if name == :-@
199
+ # RCAS.integrate(f, x) is the same call as a bare integrate(f, x):
200
+ # outside bin/rcas that qualified form is how the manual writes it.
201
+ # x.in?(ZZ) inside a block is the statement, not the answer, the way
202
+ # == is an Equation here and != an Inequality.
203
+ return Membership.new(receiver, args.first) if name == :in? && args.size == 1 && args.first.is_a?(Domain)
204
+ return function(name, args) if receiver.equal?(RCAS)
205
+ return formal(name, [receiver] + args) if FORMAL.include?(name) && receiver.is_a?(Expression)
206
+ lift(receiver.public_send(name, *args))
207
+ end
208
+
209
+ # integrate(f, x) / integrate(f, x: 0..1), diff(f, x, n), sum(f, k, a, b) /
210
+ # sum(f, k: 1..n), limit(f, x, a) / limit(f, x: 0), discuss(f, x) as
211
+ # unevaluated nodes.
212
+ def formal(name, args)
213
+ opts = args.last.is_a?(Hash) ? args.pop : {}
214
+ f = Expression.lift(args.shift)
215
+ case name
216
+ when :integrate
217
+ var, from, to = Functions.range_arguments(*args.values_at(0, 1, 2), opts, "integrate", discrete: false) if args.size < 2 && (opts.any? || args.size == 1)
218
+ var, from, to = args.values_at(0, 1, 2) if args.size >= 2
219
+ Integral.new(f, Expression.lift(var), from && Expression.lift(from), to && Expression.lift(to))
220
+ when :diff
221
+ var, n = args
222
+ Derivative.new(f, Expression.lift(var), n.is_a?(Num) ? n.value : (n || 1))
223
+ when :sum, :product
224
+ var, from, to = Functions.range_arguments(*args.values_at(0, 1, 2), opts, name.to_s, discrete: true)
225
+ (name == :sum ? Sum : Product).new(f, Expression.lift(var), Expression.lift(from), Expression.lift(to))
226
+ when :limit
227
+ opts = opts.dup
228
+ opts.delete(:dir)
229
+ var, point, = Functions.point_arguments(args[0], args[1], nil, opts, "limit")
230
+ Limit.new(f, Expression.lift(var), Expression.lift(point))
231
+ when :discuss
232
+ # A curve discussion has no node of its own: it is a question,
233
+ # not a value, and steps { discuss(f, x) } is what holds it.
234
+ Fn.new(:discuss, args.first ? [f, Expression.lift(args.first)] : [f])
235
+ end
236
+ end
237
+
238
+ # Keyword arguments such as `x: 0..1` (symbol keys stay symbols).
239
+ def hash_node(node)
240
+ list = node.children.first
241
+ return {} if list.nil?
242
+ list.children.compact.each_slice(2).to_h do |key, value|
243
+ k = LITERALS.include?(key.type) && key.children.first.is_a?(Symbol) ? key.children.first : build(key)
244
+ [k, build(value)]
245
+ end
246
+ end
247
+
248
+ def range_node(node)
249
+ lo, hi = node.children.map { |c| c.nil? ? nil : build(c) }
250
+ Range.new(lo, hi, node.type == :DOT3)
251
+ end
252
+
253
+ def arguments(list)
254
+ return [] if list.nil?
255
+ list.children.compact.map { |c| build(c) }
256
+ end
257
+
258
+ def constant_path(node)
259
+ case node.type
260
+ when :CONST then node.children.first.to_s
261
+ when :COLON3 then "::#{node.children.first}"
262
+ when :COLON2 then "#{constant_path(node.children[0])}::#{node.children[1]}"
263
+ end
264
+ end
265
+
266
+ # Expressions, symbols and numbers become nodes; anything else
267
+ # (a polynomial, a matrix, a string) is passed through untouched.
268
+ def lift(value)
269
+ Expression.lift(value)
270
+ rescue TypeError
271
+ value
272
+ end
273
+ end
274
+ end
275
+
276
+ def self.hold(&block) = Hold.hold(block)
277
+ end
@@ -0,0 +1,364 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RCAS
4
+ # Hypothesis tests and confidence intervals.
5
+ #
6
+ # ttest([5.1, 4.9, 5.6, 5.2, 5.0], mu: 5) # one-sample t test
7
+ # ttest(xs, ys) # Welch's two-sample t test
8
+ # ttest(xs, ys, paired: true)
9
+ # ztest(data, sigma: 2, mu: 100)
10
+ # chisquare_test([18, 22, 20, 25, 15]) # goodness of fit
11
+ # chisquare_test([[30, 20], [15, 35]]) # independence
12
+ # ftest(xs, ys) # ratio of variances
13
+ # binomial_test(9, 10) # exact, p stays a rational
14
+ # confidence_interval(data) # t interval for the mean
15
+ # confidence_interval(data, parameter: :variance)
16
+ # proportion_interval(41, 100)
17
+ #
18
+ # A test returns a TestResult: statistic, degrees of freedom, p value,
19
+ # `reject?(alpha)`. The p values come from the t, chi-square, F and normal
20
+ # CDFs and are Floats, labelled as numeric; the binomial test is exact.
21
+ # `alternative:` is :two_sided (the default), :less or :greater.
22
+ #
23
+ # Sources (keys: MANUAL.md, Sources): [Ros14, ch. 8-9]; Welch's degrees of
24
+ # freedom [Wel47]; the exact binomial p value is the sum of the outcomes no
25
+ # more probable than the observed one [Ros14, §9.7]; Wilson's score interval
26
+ # for a proportion [Wil27].
27
+ module Hypothesis
28
+ ALTERNATIVES = %i[two_sided less greater].freeze
29
+
30
+ # The outcome of a test: printable, and usable as a value.
31
+ class TestResult
32
+ attr_reader :name, :statistic_name, :statistic, :pvalue, :distribution, :alternative, :estimate, :parameters
33
+
34
+ def initialize(name:, statistic:, pvalue:, distribution:, alternative:, statistic_name: "statistic", estimate: nil, parameters: {})
35
+ @name = name
36
+ @statistic_name = statistic_name
37
+ @statistic = Expression.lift(statistic)
38
+ @pvalue = Expression.lift(pvalue)
39
+ @distribution = distribution
40
+ @alternative = alternative
41
+ @estimate = estimate
42
+ @parameters = parameters
43
+ freeze
44
+ end
45
+
46
+ # Is the null hypothesis rejected at this level?
47
+ def reject?(alpha = 0.05)
48
+ p = pvalue.evalf
49
+ raise ArgumentError, "reject?: the p value #{pvalue} is not numeric" unless p.is_a?(Numeric) && !p.is_a?(Complex)
50
+ p <= alpha
51
+ end
52
+
53
+ # Exact values print exactly, unless the fraction is unwieldy (the exact
54
+ # binomial p value for n = 100, say); the value itself stays exact.
55
+ def self.number(value)
56
+ v = Expression.lift(value)
57
+ if v.is_a?(Num) && (v.value.is_a?(Integer) || (v.value.is_a?(Rational) && v.value.denominator <= 10**6))
58
+ return v.to_s
59
+ end
60
+ f = v.evalf
61
+ f.is_a?(Numeric) ? format("%.6g", f) : v.to_s
62
+ end
63
+
64
+ def parameter_text = parameters.map { |k, v| "#{k} = #{self.class.number(v)}" }
65
+
66
+ def to_s
67
+ parts = ["#{statistic_name} = #{self.class.number(statistic)}"] + parameter_text
68
+ parts << "p = #{self.class.number(pvalue)}"
69
+ "#{name}: #{parts.join(', ')} (#{alternative.to_s.tr('_', '-')})"
70
+ end
71
+
72
+ def inspect = to_s
73
+
74
+ def to_latex(wrap: nil)
75
+ parts = ["#{LaTeX.escape(statistic_name)} = #{self.class.number(statistic)}"] +
76
+ parameters.map { |k, v| "#{LaTeX.escape(k.to_s)} = #{self.class.number(v)}" } +
77
+ ["p = #{self.class.number(pvalue)}"]
78
+ "\\text{#{LaTeX.escape(name)}}: #{parts.join(',\; ')}"
79
+ end
80
+ end
81
+
82
+ module_function
83
+
84
+ def check_alternative(alternative)
85
+ raise ArgumentError, "alternative: use #{ALTERNATIVES.join(', ')}" unless ALTERNATIVES.include?(alternative)
86
+ alternative
87
+ end
88
+
89
+ def float(value, name)
90
+ v = Expression.lift(value).evalf
91
+ raise ArgumentError, "#{name}: a real number is needed, got #{value}" unless v.is_a?(Numeric) && !v.is_a?(Complex)
92
+ v.to_f
93
+ end
94
+
95
+ # P(|T| >= |t|), P(T <= t) or P(T >= t) for a symmetric or one-sided statistic.
96
+ # The upper tail is the distribution's survival function, not 1 - cdf:
97
+ # z = 10 has p = 1.5e-23, and 1 - cdf said 0 (third review, P-11).
98
+ def tail(distribution, statistic, alternative, symmetric: true)
99
+ t = float(statistic, "p value")
100
+ case alternative
101
+ when :less then float(distribution.cdf(t), "p value")
102
+ when :greater then float(distribution.survival(t), "p value")
103
+ else
104
+ if symmetric
105
+ [2.0 * float(distribution.survival(t.abs), "p value"), 1.0].min
106
+ else
107
+ lower = float(distribution.cdf(t), "p value")
108
+ upper = float(distribution.survival(t), "p value")
109
+ [2.0 * [lower, upper].min, 1.0].min
110
+ end
111
+ end
112
+ end
113
+
114
+ # ---- t tests ---------------------------------------------------------------------
115
+
116
+ def ttest(data, other = nil, mu: 0, alternative: :two_sided, paired: false, equal_variance: false)
117
+ check_alternative(alternative)
118
+ return one_sample_t(data, mu, alternative) if other.nil?
119
+ if paired
120
+ xs = Statistics.data(data, "ttest")
121
+ ys = Statistics.data(other, "ttest")
122
+ raise ArgumentError, "ttest: paired samples must have the same length" unless xs.size == ys.size
123
+ return one_sample_t(xs.zip(ys).map { |x, y| (x - y).simplify }, mu, alternative, name: "paired t test")
124
+ end
125
+ two_sample_t(data, other, mu, alternative, equal_variance)
126
+ end
127
+
128
+ def one_sample_t(data, mu, alternative, name: "one-sample t test")
129
+ values = Statistics.data(data, "ttest")
130
+ n = values.size
131
+ raise ArgumentError, "ttest: at least two values are needed" if n < 2
132
+ mean = Statistics.mean(values)
133
+ error = (Statistics.stdev(values) / RCAS.sqrt(Num.new(n))).simplify
134
+ raise ArgumentError, "ttest: the sample has no spread" if Scalar.zero?(error)
135
+ t = ((mean - Expression.lift(mu)) / error).simplify
136
+ df = n - 1
137
+ TestResult.new(name: name, statistic_name: "t", statistic: t, distribution: Distributions::StudentT.new(df),
138
+ pvalue: Num.new(tail(Distributions::StudentT.new(df), t, alternative)),
139
+ alternative: alternative, estimate: mean, parameters: { df: df })
140
+ end
141
+
142
+ # Welch by default (unequal variances), the pooled test with equal_variance: true.
143
+ def two_sample_t(xs, ys, mu, alternative, equal_variance)
144
+ xs = Statistics.data(xs, "ttest")
145
+ ys = Statistics.data(ys, "ttest")
146
+ raise ArgumentError, "ttest: at least two values per sample are needed" if xs.size < 2 || ys.size < 2
147
+ n = xs.size
148
+ m = ys.size
149
+ vx = float(Statistics.variance(xs), "ttest")
150
+ vy = float(Statistics.variance(ys), "ttest")
151
+ difference = float(Statistics.mean(xs), "ttest") - float(Statistics.mean(ys), "ttest") - float(mu, "ttest")
152
+ if equal_variance
153
+ pooled = ((n - 1) * vx + (m - 1) * vy) / (n + m - 2.0)
154
+ error = Math.sqrt(pooled * (1.0 / n + 1.0 / m))
155
+ df = n + m - 2
156
+ name = "two-sample t test"
157
+ else
158
+ error = Math.sqrt(vx / n + vy / m)
159
+ df = (vx / n + vy / m)**2 / ((vx / n)**2 / (n - 1) + (vy / m)**2 / (m - 1))
160
+ name = "Welch t test"
161
+ end
162
+ raise ArgumentError, "ttest: the samples have no spread" if error.zero?
163
+ t = difference / error
164
+ distribution = Distributions::StudentT.new(df)
165
+ TestResult.new(name: name, statistic_name: "t", statistic: Num.new(t), distribution: distribution,
166
+ pvalue: Num.new(tail(distribution, t, alternative)), alternative: alternative,
167
+ estimate: Num.new(difference), parameters: { df: Num.new(df) })
168
+ end
169
+
170
+ # ---- z test ----------------------------------------------------------------------
171
+
172
+ def ztest(data, sigma:, mu: 0, alternative: :two_sided)
173
+ check_alternative(alternative)
174
+ values = Statistics.data(data, "ztest")
175
+ n = values.size
176
+ mean = Statistics.mean(values)
177
+ z = ((mean - Expression.lift(mu)) / (Expression.lift(sigma) / RCAS.sqrt(Num.new(n)))).simplify
178
+ normal = Distributions::Normal.new(0, 1)
179
+ TestResult.new(name: "z test", statistic_name: "z", statistic: z, distribution: normal,
180
+ pvalue: Num.new(tail(normal, z, alternative)), alternative: alternative,
181
+ estimate: mean, parameters: { n: n })
182
+ end
183
+
184
+ # ---- chi-square ------------------------------------------------------------------
185
+
186
+ # A flat list is a goodness-of-fit test against +expected+ (counts or
187
+ # probabilities, uniform by default); a list of rows is a test of
188
+ # independence. +df:+ lowers the degrees of freedom for estimated parameters.
189
+ def chisquare_test(observed, expected: nil, df: nil, alternative: :greater)
190
+ check_alternative(alternative)
191
+ rows = observed.is_a?(Matrix) ? observed.to_a : observed
192
+ return independence_test(rows) if rows.is_a?(Array) && rows.first.is_a?(Array)
193
+ goodness_of_fit(rows, expected, df, alternative)
194
+ end
195
+
196
+ def goodness_of_fit(observed, expected, df, alternative)
197
+ counts = Statistics.data(observed, "chisquare_test")
198
+ total = counts.reduce(:+).simplify
199
+ expected = if expected.nil?
200
+ Array.new(counts.size) { (total / counts.size).simplify }
201
+ else
202
+ e = Statistics.data(expected, "chisquare_test")
203
+ raise ArgumentError, "chisquare_test: #{e.size} expected values for #{counts.size} observed ones" unless e.size == counts.size
204
+ sum = e.reduce(:+).simplify
205
+ # probabilities sum to 1 and counts to the number observed;
206
+ # anything else is an input error, as R has it (P-16)
207
+ if Scalar.zero?((sum - 1).simplify) || close?(sum, 1)
208
+ e.map { |v| (v * total).simplify }
209
+ elsif Scalar.zero?((sum - total).simplify) || close?(sum, total)
210
+ e
211
+ elsif e.all? { |v| float(v, "chisquare_test") <= 1 }
212
+ raise ArgumentError, "chisquare_test: the expected probabilities sum to #{sum}, not 1"
213
+ else
214
+ raise ArgumentError, "chisquare_test: the expected counts sum to #{sum}, not to the #{total} observed"
215
+ end
216
+ end
217
+ expected.each { |v| raise ArgumentError, "chisquare_test: expected counts must be positive" if float(v, "chisquare_test") <= 0 }
218
+ statistic = counts.zip(expected).map { |o, e| ((o - e)**2 / e).expand }.reduce(:+).simplify
219
+ degrees = df || counts.size - 1
220
+ distribution = Distributions::ChiSquare.new(degrees)
221
+ TestResult.new(name: "chi-square goodness of fit", statistic_name: "X^2", statistic: statistic,
222
+ distribution: distribution, pvalue: Num.new(tail(distribution, statistic, alternative, symmetric: false)),
223
+ alternative: alternative, parameters: { df: degrees })
224
+ end
225
+
226
+ def close?(a, b)
227
+ x = float(a, "chisquare_test")
228
+ y = float(b, "chisquare_test")
229
+ (x - y).abs <= 1e-9 * [1.0, y.abs].max
230
+ end
231
+
232
+ def independence_test(rows)
233
+ table = rows.map { |row| Statistics.data(row, "chisquare_test") }
234
+ width = table.first.size
235
+ raise ArgumentError, "chisquare_test: the rows must have the same length" unless table.all? { |r| r.size == width }
236
+ raise ArgumentError, "chisquare_test: a table of at least 2 by 2 is needed" if table.size < 2 || width < 2
237
+ total = table.flatten.reduce(:+).simplify
238
+ row_sums = table.map { |r| r.reduce(:+).simplify }
239
+ column_sums = (0...width).map { |j| table.map { |r| r[j] }.reduce(:+).simplify }
240
+ statistic = table.each_with_index.map do |row, i|
241
+ row.each_with_index.map do |observed, j|
242
+ expected = (row_sums[i] * column_sums[j] / total).simplify
243
+ raise ArgumentError, "chisquare_test: a row or column sums to zero" if Scalar.zero?(expected)
244
+ ((observed - expected)**2 / expected).expand
245
+ end.reduce(:+)
246
+ end.reduce(:+).simplify
247
+ degrees = (table.size - 1) * (width - 1)
248
+ distribution = Distributions::ChiSquare.new(degrees)
249
+ TestResult.new(name: "chi-square test of independence", statistic_name: "X^2", statistic: statistic,
250
+ distribution: distribution, pvalue: Num.new(tail(distribution, statistic, :greater, symmetric: false)),
251
+ alternative: :greater, parameters: { df: degrees })
252
+ end
253
+
254
+ # ---- F test ----------------------------------------------------------------------
255
+
256
+ def ftest(xs, ys, alternative: :two_sided)
257
+ check_alternative(alternative)
258
+ xs = Statistics.data(xs, "ftest")
259
+ ys = Statistics.data(ys, "ftest")
260
+ raise ArgumentError, "ftest: at least two values per sample are needed" if xs.size < 2 || ys.size < 2
261
+ vx = Statistics.variance(xs)
262
+ vy = Statistics.variance(ys)
263
+ raise ArgumentError, "ftest: the second sample has no spread" if Scalar.zero?(vy)
264
+ f = (vx / vy).simplify
265
+ distribution = Distributions::FRatio.new(xs.size - 1, ys.size - 1)
266
+ TestResult.new(name: "F test of two variances", statistic_name: "F", statistic: f, distribution: distribution,
267
+ pvalue: Num.new(tail(distribution, f, alternative, symmetric: false)), alternative: alternative,
268
+ estimate: f, parameters: { df1: xs.size - 1, df2: ys.size - 1 })
269
+ end
270
+
271
+ # ---- exact binomial test ------------------------------------------------------------
272
+
273
+ # Two-sided: the sum of the probabilities of all outcomes no more probable
274
+ # than the observed one. Exact, so the p value stays a rational.
275
+ def binomial_test(successes, trials, p: Rational(1, 2), alternative: :two_sided)
276
+ check_alternative(alternative)
277
+ k = successes.is_a?(Num) ? successes.value : successes
278
+ n = trials.is_a?(Num) ? trials.value : trials
279
+ raise ArgumentError, "binomial_test: 0 <= successes <= trials is needed" unless k.is_a?(Integer) && n.is_a?(Integer) && k >= 0 && k <= n
280
+ distribution = Distributions::Binomial.new(n, p)
281
+ probabilities = exact_binomial_pmf(n, p) || (0..n).map { |j| distribution.pdf(j) }
282
+ pvalue =
283
+ case alternative
284
+ when :less then probabilities[0..k].reduce(:+)
285
+ when :greater then probabilities[k..].reduce(:+)
286
+ else
287
+ # "no more probable than the observed one", compared exactly when
288
+ # the probabilities are exact: as Floats, 2**-1100 underflowed and
289
+ # every outcome was as improbable as k = 0 (third review, P-12)
290
+ observed = probabilities[k]
291
+ exact = probabilities.all? { |q| q.is_a?(Rational) || q.is_a?(Integer) }
292
+ if exact
293
+ probabilities.select { |q| q <= observed }.sum
294
+ else
295
+ reference = float(observed, "binomial_test")
296
+ probabilities.select { |q| float(q, "binomial_test") <= reference * (1 + 1e-9) }.reduce(:+)
297
+ end
298
+ end
299
+ TestResult.new(name: "exact binomial test", statistic_name: "k", statistic: Num.new(k), distribution: distribution,
300
+ pvalue: Expression.lift(pvalue).simplify, alternative: alternative,
301
+ estimate: Num.new(Simplify.normalize_number(Rational(k, n))), parameters: { n: n })
302
+ end
303
+
304
+ # The pmf of Binomial(n, p) for a rational p as Rationals, the
305
+ # binomial coefficients built up one from the next; nil otherwise.
306
+ def exact_binomial_pmf(n, p)
307
+ q = p.is_a?(Num) ? p.value : p
308
+ return nil unless q.is_a?(Rational) || q.is_a?(Integer)
309
+ q = Rational(q)
310
+ coefficient = 1
311
+ (0..n).map do |j|
312
+ coefficient = coefficient * (n - j + 1) / j unless j.zero?
313
+ coefficient * q**j * (1 - q)**(n - j)
314
+ end
315
+ end
316
+
317
+ # ---- confidence intervals -------------------------------------------------------------
318
+
319
+ # For the mean (Student t, or normal when sigma is known), the variance or
320
+ # the standard deviation of a sample. Returns an Interval.
321
+ def confidence_interval(data, level: 0.95, sigma: nil, parameter: :mean)
322
+ values = Statistics.data(data, "confidence_interval")
323
+ n = values.size
324
+ raise ArgumentError, "confidence_interval: at least two values are needed" if n < 2
325
+ alpha = 1.0 - float(level, "confidence_interval")
326
+ raise ArgumentError, "confidence_interval: level must be in (0, 1)" unless alpha.positive? && alpha < 1
327
+ case parameter
328
+ when :mean
329
+ mean = float(Statistics.mean(values), "confidence_interval")
330
+ if sigma
331
+ error = float(sigma, "confidence_interval") / Math.sqrt(n)
332
+ critical = float(Distributions::Normal.new(0, 1).quantile(1 - alpha / 2), "confidence_interval")
333
+ else
334
+ error = float(Statistics.stdev(values), "confidence_interval") / Math.sqrt(n)
335
+ critical = float(Distributions::StudentT.new(n - 1).quantile(1 - alpha / 2), "confidence_interval")
336
+ end
337
+ Interval.closed(Num.new(mean - critical * error), Num.new(mean + critical * error))
338
+ when :variance, :stdev
339
+ scaled = (n - 1) * float(Statistics.variance(values), "confidence_interval")
340
+ chi = Distributions::ChiSquare.new(n - 1)
341
+ upper = scaled / float(chi.quantile(alpha / 2), "confidence_interval")
342
+ lower = scaled / float(chi.quantile(1 - alpha / 2), "confidence_interval")
343
+ lower, upper = [Math.sqrt(lower), Math.sqrt(upper)] if parameter == :stdev
344
+ Interval.closed(Num.new(lower), Num.new(upper))
345
+ else raise ArgumentError, "confidence_interval: parameter must be :mean, :variance or :stdev"
346
+ end
347
+ end
348
+
349
+ # Wilson's score interval for a proportion.
350
+ def proportion_interval(successes, trials, level: 0.95)
351
+ k = float(successes, "proportion_interval")
352
+ n = float(trials, "proportion_interval")
353
+ raise ArgumentError, "proportion_interval: 0 <= successes <= trials is needed" unless k >= 0 && k <= n && n.positive?
354
+ alpha = 1.0 - float(level, "proportion_interval")
355
+ z = float(Distributions::Normal.new(0, 1).quantile(1 - alpha / 2), "proportion_interval")
356
+ phat = k / n
357
+ centre = (phat + z * z / (2 * n)) / (1 + z * z / n)
358
+ spread = z * Math.sqrt(phat * (1 - phat) / n + z * z / (4 * n * n)) / (1 + z * z / n)
359
+ low = k.zero? ? 0.0 : [centre - spread, 0.0].max
360
+ high = k == n ? 1.0 : [centre + spread, 1.0].min
361
+ Interval.closed(Num.new(low), Num.new(high))
362
+ end
363
+ end
364
+ end