Skip to content

Commit 2a73fb0

Browse files
Add regression tests for the NaiveBayes predict and option fixes
Each test was checked against the pre-fix code: seven of them fail there and pass here, so they detect the bugs rather than merely documenting current behaviour. Expected values come from sklearn.naive_bayes, not from Scholar's own output. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent b1d5941 commit 2a73fb0

2 files changed

Lines changed: 171 additions & 0 deletions

File tree

test/scholar/naive_bayes/bernoulli_test.exs

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,5 +169,90 @@ defmodule Scholar.NaiveBayes.BernoulliTest do
169169
expected_predictions = Nx.tensor([2, 1])
170170
assert predictions == expected_predictions
171171
end
172+
173+
test "applies binarize to the input at predict time, not only at fit time" do
174+
x = Nx.iota({4, 3})
175+
y = Nx.tensor([1, 2, 0, 2])
176+
model = Bernoulli.fit(x, y, num_classes: 3)
177+
178+
x_test = Nx.tensor([[6, 2, 4], [8, 5, 9]])
179+
180+
# Every entry of x_test is above the default threshold of 0.0, so both
181+
# rows binarize to all-ones and must therefore score identically.
182+
# Reference: sklearn.naive_bayes.BernoulliNB(binarize=0.0) on the same
183+
# data returns these probabilities for both rows.
184+
assert_all_close(
185+
Bernoulli.predict_probability(model, x_test),
186+
Nx.tensor([
187+
[0.23000899, 0.11500449, 0.65498656],
188+
[0.23000899, 0.11500449, 0.65498656]
189+
])
190+
)
191+
192+
# Passing the already-binarized input must give the same answer.
193+
x_test_binarized = Scholar.Preprocessing.Binarizer.fit_transform(x_test, threshold: 0.0)
194+
model_no_binarize = Bernoulli.fit(x, y, num_classes: 3)
195+
196+
assert_all_close(
197+
Bernoulli.predict_probability(model_no_binarize, x_test_binarized),
198+
Bernoulli.predict_probability(model, x_test)
199+
)
200+
end
201+
202+
test "respects a non-default binarize threshold at predict time" do
203+
x = Nx.iota({4, 3})
204+
y = Nx.tensor([1, 2, 0, 2])
205+
model = Bernoulli.fit(x, y, num_classes: 3, binarize: 5.0)
206+
207+
x_test = Nx.tensor([[6, 2, 4], [8, 5, 9]])
208+
209+
# With threshold 5.0 the two rows binarize differently ([1,0,0] and
210+
# [1,0,1]), so unlike the default-threshold case they must not score
211+
# identically.
212+
probability = Bernoulli.predict_probability(model, x_test)
213+
refute Nx.to_number(Nx.all_close(probability[0], probability[1])) == 1
214+
end
215+
end
216+
217+
describe "option validation" do
218+
test "rejects a negative alpha instead of silently producing NaN" do
219+
x = Nx.tensor([[1, 0, 1], [0, 1, 1], [1, 1, 0]])
220+
y = Nx.tensor([0, 1, 1])
221+
222+
assert_raise NimbleOptions.ValidationError, fn ->
223+
Bernoulli.fit(x, y, num_classes: 2, alpha: -1.0)
224+
end
225+
226+
assert_raise NimbleOptions.ValidationError, fn ->
227+
Bernoulli.fit(x, y, num_classes: 2, alpha: [1.0, -2.0, 1.0])
228+
end
229+
end
230+
231+
test "keeps class_log_priors in the same type as the rest of the model" do
232+
x = Nx.tensor([[1, 0, 1], [0, 1, 1], [1, 1, 0]], type: :f64)
233+
y = Nx.tensor([0, 1, 1])
234+
235+
for opts <- [
236+
[num_classes: 2],
237+
[num_classes: 2, class_priors: [0.3, 0.7]],
238+
[num_classes: 2, fit_priors: false]
239+
] do
240+
model = Bernoulli.fit(x, y, opts)
241+
242+
assert Nx.type(model.class_log_priors) == Nx.type(model.feature_log_probability),
243+
"class_log_priors downcast for opts: #{inspect(opts)}"
244+
end
245+
end
246+
247+
test "computes the uniform prior in the model type, not in f32" do
248+
x = Nx.tensor([[1, 0, 1], [0, 1, 1], [1, 1, 0]], type: :f64)
249+
y = Nx.tensor([0, 1, 1])
250+
model = Bernoulli.fit(x, y, num_classes: 3, fit_priors: false)
251+
252+
# -log(3) must be accurate to f64, not an f32 value widened to f64.
253+
assert_all_close(model.class_log_priors[0], Nx.tensor(-:math.log(3), type: :f64),
254+
atol: 1.0e-15
255+
)
256+
end
172257
end
173258
end

test/scholar/naive_bayes/categorical_test.exs

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1027,4 +1027,90 @@ defmodule Scholar.NaiveBayes.CategoricalTest do
10271027
)
10281028
end
10291029
end
1030+
1031+
describe "predict with num_features != num_classes" do
1032+
test "predicts when the feature count differs from the class count" do
1033+
# Every doctest and existing test happens to use num_features ==
1034+
# num_classes, which is the only case where a jll accumulator sized
1035+
# from x instead of from the class axis works.
1036+
x = Nx.tensor([[1, 2, 2], [1, 2, 1], [2, 2, 0], [0, 1, 2], [2, 0, 1]])
1037+
y = Nx.tensor([0, 1, 1, 0, 1])
1038+
model = Categorical.fit(x, y, num_classes: 2)
1039+
1040+
x_test = Nx.tensor([[1, 2, 2], [2, 0, 1], [0, 1, 0]])
1041+
1042+
assert Nx.shape(Categorical.predict_probability(model, x_test)) == {3, 2}
1043+
assert Categorical.predict(model, x_test, Nx.tensor([0, 1])) == Nx.tensor([0, 1, 0])
1044+
1045+
# Reference: sklearn.naive_bayes.CategoricalNB on the same data.
1046+
assert_all_close(
1047+
Categorical.predict_probability(model, x_test),
1048+
Nx.tensor([
1049+
[0.6973365617433412, 0.3026634382566586],
1050+
[0.060150375939849614, 0.9398496240601504],
1051+
[0.6973365617433416, 0.3026634382566585]
1052+
]),
1053+
atol: 1.0e-5
1054+
)
1055+
end
1056+
1057+
test "check_dim validates against the feature count, not the class count" do
1058+
x = Nx.tensor([[1, 2, 2], [1, 2, 1], [2, 2, 0], [0, 1, 2], [2, 0, 1]])
1059+
y = Nx.tensor([0, 1, 1, 0, 1])
1060+
model = Categorical.fit(x, y, num_classes: 2)
1061+
1062+
# 3 features, as used for fitting: must be accepted.
1063+
assert Nx.shape(Categorical.predict_probability(model, Nx.tensor([[1, 2, 2]]))) == {1, 2}
1064+
1065+
# 2 features: must be rejected even though it matches num_classes.
1066+
assert_raise ArgumentError, fn ->
1067+
Categorical.predict_probability(model, Nx.tensor([[1, 2]]))
1068+
end
1069+
end
1070+
end
1071+
1072+
describe "option validation" do
1073+
test "rejects a negative alpha instead of silently producing NaN" do
1074+
x = Nx.tensor([[1, 0, 1], [0, 1, 1], [1, 1, 0]])
1075+
y = Nx.tensor([0, 1, 1])
1076+
1077+
assert_raise NimbleOptions.ValidationError, fn ->
1078+
Categorical.fit(x, y, num_classes: 2, alpha: -1.0)
1079+
end
1080+
1081+
assert_raise NimbleOptions.ValidationError, fn ->
1082+
Categorical.fit(x, y, num_classes: 2, alpha: [1.0, -2.0, 1.0])
1083+
end
1084+
end
1085+
1086+
test "min_categories actually sizes the category axis" do
1087+
x = Nx.tensor([[1, 2, 3], [1, 3, 4], [2, 2, 3], [1, 1, 3], [2, 1, 4]])
1088+
y = Nx.tensor([0, 1, 2, 1, 0])
1089+
1090+
# Without the option the category count is inferred from the data (max 4
1091+
# -> 5 categories). Asking for 7 must widen the axis to 7, matching
1092+
# sklearn's CategoricalNB(min_categories=...) semantics.
1093+
inferred = Categorical.fit(x, y, num_classes: 3)
1094+
assert Nx.axis_size(inferred.feature_count, 2) == 5
1095+
1096+
widened = Categorical.fit(x, y, num_classes: 3, min_categories: [7, 7, 7])
1097+
assert Nx.axis_size(widened.feature_count, 2) == 7
1098+
end
1099+
1100+
test "keeps class_log_priors in the same type as the rest of the model" do
1101+
x = Nx.tensor([[1, 0, 1], [0, 1, 1], [1, 1, 0]])
1102+
y = Nx.tensor([0, 1, 1])
1103+
1104+
for opts <- [
1105+
[num_classes: 2],
1106+
[num_classes: 2, class_priors: [0.3, 0.7]],
1107+
[num_classes: 2, fit_priors: false]
1108+
] do
1109+
model = Categorical.fit(x, y, opts)
1110+
1111+
assert Nx.type(model.class_log_priors) == Nx.type(model.feature_log_probability),
1112+
"class_log_priors downcast for opts: #{inspect(opts)}"
1113+
end
1114+
end
1115+
end
10301116
end

0 commit comments

Comments
 (0)