Skip to content

Commit 96101fb

Browse files
authored
Merge pull request #1 from Intellection/students-t
Student's T Distribution (standardised)
2 parents ea1e240 + e372030 commit 96101fb

File tree

9 files changed

+265
-6
lines changed

9 files changed

+265
-6
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,6 @@ exstatic-*.tar
2424

2525
# Temporary files, for example, from tests.
2626
/tmp/
27+
28+
# Shared binary artifacts (compiled NIFs).
29+
priv/native/*.so

lib/exstatic/distribution.ex

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
11
defmodule Exstatic.Distribution do
22
@type t() :: struct()
33
@type error() :: {:error, atom()}
4-
@type result(t) :: {:ok, t} | error()
54

6-
@callback new(mean :: float(), std_dev :: float()) :: result(t())
75
@callback mean(distribution :: t()) :: float()
8-
@callback variance(distribution :: t()) :: float()
6+
@callback variance(distribution :: t()) :: float() | :undefined | :infinity
97
@callback std_dev(distribution :: t()) :: float()
108
@callback entropy(distribution :: t()) :: float()
119
@callback skewness(distribution :: t()) :: float()

lib/exstatic/distribution/normal.ex

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,6 @@ defmodule Exstatic.Distribution.Normal do
5252
iex> Normal.new(0.0, -1.0)
5353
{:error, :invalid_std_dev}
5454
"""
55-
@impl Exstatic.Distribution
5655
def new(mean, std_dev) when is_number(mean) and is_number(std_dev) do
5756
if std_dev <= 0 do
5857
{:error, :invalid_std_dev}

lib/exstatic/distribution/t.ex

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
defmodule Exstatic.Distribution.StandardizedT do
2+
@moduledoc """
3+
The standardized Student's t-distribution, used in statistical hypothesis testing.
4+
5+
This implementation ensures that:
6+
- The mean is always `0.0`.
7+
- The variance exists for `df > 1` (it is infinite for `1 < df ≤ 2`).
8+
- The distribution is well-defined only for `df > 1`.
9+
10+
## Examples
11+
12+
iex> alias Exstatic.Distribution.StandardizedT
13+
iex> {:ok, t} = StandardizedT.new(5.0)
14+
iex> StandardizedT.mean(t)
15+
0.0
16+
iex> pdf = StandardizedT.pdf(t, 0.0)
17+
iex> TestHelper.assert_in_delta(pdf, 0.37960669, 1.0e-6)
18+
true
19+
iex> result = StandardizedT.cdf(t, 0.0)
20+
iex> TestHelper.assert_in_delta(result, 0.5)
21+
true
22+
"""
23+
24+
@behaviour Exstatic.Distribution
25+
@behaviour Exstatic.Continuous
26+
@behaviour Exstatic.ContinuousCDF
27+
28+
defstruct [:df]
29+
30+
@type t :: %__MODULE__{df: float()}
31+
32+
@doc """
33+
Creates a new standardized Student's t-distribution with the given degrees of freedom.
34+
35+
## Parameters
36+
- `df` - The degrees of freedom (`df > 1` required).
37+
38+
## Examples
39+
40+
iex> alias Exstatic.Distribution.StandardizedT
41+
iex> StandardizedT.new(5.0)
42+
{:ok, %StandardizedT{df: 5.0}}
43+
44+
iex> StandardizedT.new(1.0)
45+
{:error, :invalid_df}
46+
47+
iex> StandardizedT.new(-5.0)
48+
{:error, :invalid_df}
49+
"""
50+
def new(df) when is_number(df) and df > 1 do
51+
{:ok, %__MODULE__{df: df}}
52+
end
53+
54+
def new(_df), do: {:error, :invalid_df}
55+
56+
@doc """
57+
Returns the mean of the t-distribution.
58+
59+
The mean is always `0.0` for standardized t-distributions since `df > 1`.
60+
61+
## Examples
62+
63+
iex> {:ok, t} = StandardizedT.new(5.0)
64+
iex> StandardizedT.mean(t)
65+
0.0
66+
"""
67+
@impl Exstatic.Distribution
68+
def mean(_t), do: 0.0
69+
70+
@doc """
71+
Returns the variance of the t-distribution.
72+
73+
- If `1 < df ≤ 2`, the variance is `:infinity`.
74+
- Otherwise, the variance is computed using `Exstatic.Native.standardized_t_variance/1`.
75+
76+
## Examples
77+
78+
iex> {:ok, t} = StandardizedT.new(5.0)
79+
iex> TestHelper.assert_in_delta(StandardizedT.variance(t), 5.0 / (5.0 - 2.0), 1.0e-10)
80+
true
81+
82+
iex> {:ok, t} = StandardizedT.new(1.5)
83+
iex> StandardizedT.variance(t)
84+
:infinity
85+
"""
86+
@impl Exstatic.Distribution
87+
@spec variance(t) :: float() | :infinity
88+
def variance(%__MODULE__{df: df}) do
89+
if df <= 2.0, do: :infinity, else: Exstatic.Native.standardized_t_variance(df)
90+
end
91+
92+
@doc """
93+
Computes the probability density function (PDF) at `x`.
94+
95+
## Examples
96+
97+
iex> {:ok, t} = StandardizedT.new(5.0)
98+
iex> TestHelper.assert_in_delta(StandardizedT.pdf(t, 0.0), 0.37960669, 1.0e-6)
99+
true
100+
"""
101+
@impl Exstatic.Continuous
102+
def pdf(%__MODULE__{} = dist, x) when is_number(x) do
103+
Exstatic.Native.standardized_t_pdf(dist.df, x)
104+
end
105+
106+
@doc """
107+
Computes the cumulative distribution function (CDF) at `x`.
108+
109+
## Examples
110+
111+
iex> {:ok, t} = StandardizedT.new(5.0)
112+
iex> TestHelper.assert_in_delta(StandardizedT.cdf(t, 0.0), 0.5, 1.0e-10)
113+
true
114+
"""
115+
@impl Exstatic.ContinuousCDF
116+
def cdf(%__MODULE__{} = dist, x) when is_number(x) do
117+
Exstatic.Native.standardized_t_cdf(dist.df, x)
118+
end
119+
120+
@doc """
121+
Computes the survival function (SF) at `x`, which is `1 - CDF(x)`.
122+
123+
## Examples
124+
125+
iex> {:ok, t} = StandardizedT.new(5.0)
126+
iex> TestHelper.assert_in_delta(StandardizedT.sf(t, 0.0), 0.5, 1.0e-10)
127+
true
128+
"""
129+
@impl Exstatic.ContinuousCDF
130+
def sf(%__MODULE__{} = dist, x) when is_number(x) do
131+
Exstatic.Native.standardized_t_sf(dist.df, x)
132+
end
133+
end

lib/exstatic/native.ex

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,17 @@ defmodule Exstatic.Native do
2222

2323
@spec normal_variance(float()) :: float()
2424
def normal_variance(_std_dev), do: :erlang.nif_error(:nif_not_loaded)
25+
26+
@spec standardized_t_pdf(float(), float()) :: float()
27+
def standardized_t_pdf(_df, _x), do: :erlang.nif_error(:nif_not_loaded)
28+
29+
@spec standardized_t_cdf(float(), float()) :: float()
30+
def standardized_t_cdf(_df, _x), do: :erlang.nif_error(:nif_not_loaded)
31+
32+
@spec standardized_t_sf(float(), float()) :: float()
33+
def standardized_t_sf(_df, _x), do: :erlang.nif_error(:nif_not_loaded)
34+
35+
@spec standardized_t_variance(float()) :: {:ok, float()} | {:error, String.t()}
36+
def standardized_t_variance(_df), do: :erlang.nif_error(:nif_not_loaded)
2537
end
38+

mix.lock

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
%{
22
"finch": {:hex, :finch, "0.19.0", "c644641491ea854fc5c1bbaef36bfc764e3f08e7185e1f084e35e0672241b76d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "fc5324ce209125d1e2fa0fcd2634601c52a787aff1cd33ee833664a5af4ea2b6"},
3-
"hpax": {:hex, :hpax, "1.0.1", "c857057f89e8bd71d97d9042e009df2a42705d6d690d54eca84c8b29af0787b0", [:mix], [], "hexpm", "4e2d5a4f76ae1e3048f35ae7adb1641c36265510a2d4638157fbcb53dda38445"},
3+
"hpax": {:hex, :hpax, "1.0.2", "762df951b0c399ff67cc57c3995ec3cf46d696e41f0bba17da0518d94acd4aac", [:mix], [], "hexpm", "2f09b4c1074e0abd846747329eaa26d535be0eb3d189fa69d812bfb8bfefd32f"},
44
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
55
"mime": {:hex, :mime, "2.0.6", "8f18486773d9b15f95f4f4f1e39b710045fa1de891fada4516559967276e4dc2", [:mix], [], "hexpm", "c9945363a6b26d747389aac3643f8e0e09d30499a138ad64fe8fd1d13d9b153e"},
66
"mint": {:hex, :mint, "1.6.2", "af6d97a4051eee4f05b5500671d47c3a67dac7386045d87a904126fd4bbcea2e", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "5ee441dffc1892f1ae59127f74afe8fd82fda6587794278d924e4d90ea3d63f9"},

native/exstatic/src/lib.rs

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use rustler::{Error, NifResult};
2-
use statrs::distribution::{Continuous, ContinuousCDF, Normal};
2+
use statrs::distribution::{Continuous, ContinuousCDF, Normal, StudentsT};
33
use statrs::statistics::Distribution;
44

55
#[rustler::nif]
@@ -51,4 +51,46 @@ fn normal_inverse_cdf(mean: f64, std_dev: f64, p: f64) -> NifResult<f64> {
5151
Ok(normal.inverse_cdf(p))
5252
}
5353

54+
#[rustler::nif]
55+
fn standardized_t_pdf(df: f64, x: f64) -> NifResult<f64> {
56+
if df <= 1.0 {
57+
return Err(Error::Term(Box::new("Degrees of freedom must be greater than 1")));
58+
}
59+
60+
let t = StudentsT::new(0.0, 1.0, df).map_err(|e| Error::Term(Box::new(e.to_string())))?;
61+
Ok(t.pdf(x))
62+
}
63+
64+
#[rustler::nif]
65+
fn standardized_t_cdf(df: f64, x: f64) -> NifResult<f64> {
66+
if df <= 1.0 {
67+
return Err(Error::Term(Box::new("Degrees of freedom must be greater than 1")));
68+
}
69+
70+
let t = StudentsT::new(0.0, 1.0, df).map_err(|e| Error::Term(Box::new(e.to_string())))?;
71+
Ok(t.cdf(x))
72+
}
73+
74+
#[rustler::nif]
75+
fn standardized_t_sf(df: f64, x: f64) -> NifResult<f64> {
76+
if df <= 1.0 {
77+
return Err(Error::Term(Box::new("Degrees of freedom must be greater than 1")));
78+
}
79+
80+
let t = StudentsT::new(0.0, 1.0, df).map_err(|e| Error::Term(Box::new(e.to_string())))?;
81+
Ok(1.0 - t.cdf(x))
82+
}
83+
84+
#[rustler::nif]
85+
fn standardized_t_variance(df: f64) -> NifResult<f64> {
86+
if df <= 1.0 {
87+
return Err(Error::Term(Box::new("Variance is undefined for df ≤ 1")));
88+
} else if df > 1.0 && df <= 2.0 {
89+
return Err(Error::Term(Box::new("Variance is infinite for 1 < df ≤ 2")));
90+
}
91+
92+
let t = StudentsT::new(0.0, 1.0, df).map_err(|e| Error::Term(Box::new(e.to_string())))?;
93+
t.variance().ok_or_else(|| Error::Term(Box::new("Failed to calculate variance")))
94+
}
95+
5496
rustler::init!("Elixir.Exstatic.Native");

priv/native/libexstatic.so

-432 KB
Binary file not shown.
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
defmodule Exstatic.Distribution.StandardizedTTest do
2+
use ExUnit.Case, async: true
3+
4+
alias Exstatic.Distribution.StandardizedT
5+
6+
doctest Exstatic.Distribution.StandardizedT
7+
8+
describe "new/1" do
9+
test "creates a valid standardized t-distribution" do
10+
assert {:ok, _t} = StandardizedT.new(5.0)
11+
end
12+
13+
test "returns error for invalid degrees of freedom" do
14+
assert {:error, :invalid_df} = StandardizedT.new(1.0)
15+
assert {:error, :invalid_df} = StandardizedT.new(0.0)
16+
assert {:error, :invalid_df} = StandardizedT.new(-5.0)
17+
end
18+
end
19+
20+
describe "mean/1" do
21+
test "returns 0.0 for any valid standardized t-distribution" do
22+
{:ok, t} = StandardizedT.new(3.0)
23+
assert StandardizedT.mean(t) == 0.0
24+
25+
{:ok, t} = StandardizedT.new(10.0)
26+
assert StandardizedT.mean(t) == 0.0
27+
end
28+
end
29+
30+
describe "variance/1" do
31+
test "returns a finite variance when df > 2" do
32+
{:ok, t} = StandardizedT.new(5.0)
33+
expected_variance = 5.0 / (5.0 - 2.0)
34+
assert TestHelper.assert_in_delta(StandardizedT.variance(t), expected_variance)
35+
end
36+
37+
test "returns :infinity when 1 < df ≤ 2" do
38+
{:ok, t} = StandardizedT.new(1.5)
39+
assert StandardizedT.variance(t) == :infinity
40+
end
41+
end
42+
43+
describe "pdf/2" do
44+
test "computes valid PDF values" do
45+
{:ok, t} = StandardizedT.new(5.0)
46+
assert TestHelper.assert_in_delta(StandardizedT.pdf(t, 0.0), 0.37960669, 1.0e-6)
47+
assert TestHelper.assert_in_delta(StandardizedT.pdf(t, 1.0), 0.219679797, 1.0e-6)
48+
end
49+
end
50+
51+
describe "cdf/2" do
52+
test "computes valid CDF values" do
53+
{:ok, t} = StandardizedT.new(5.0)
54+
55+
assert TestHelper.assert_in_delta(StandardizedT.cdf(t, 0.0), 0.5, 1.0e-9)
56+
assert TestHelper.assert_in_delta(StandardizedT.cdf(t, -100.0), 0.0, 1.0e-9)
57+
assert TestHelper.assert_in_delta(StandardizedT.cdf(t, 100.0), 1.0, 1.0e-9)
58+
end
59+
end
60+
61+
describe "sf/2" do
62+
test "computes valid SF values" do
63+
{:ok, t} = StandardizedT.new(5.0)
64+
65+
assert TestHelper.assert_in_delta(StandardizedT.sf(t, 0.0), 0.5, 1.0e-9)
66+
assert TestHelper.assert_in_delta(StandardizedT.sf(t, -100.0), 1.0, 1.0e-9)
67+
assert TestHelper.assert_in_delta(StandardizedT.sf(t, 100.0), 0.0, 1.0e-9)
68+
end
69+
end
70+
end
71+

0 commit comments

Comments
 (0)