ruby · 5 min read
Machine Learning in Ruby: Train, Check and Reload a Ruby-FANN Network
Train an actual Ruby-FANN XOR network, validate inputs and reload saved weights using a documented Ruby 3.3 compatibility build.
A neural-network demo is not complete when a gem loads or a training call returns. Can it fit the stated examples? Does the saved network behave like the trained one? What happens when a request passes the wrong number of inputs?
This Ruby-FANN experiment answers those questions on the four-row XOR truth table. It trains a real native FANN network, checks a numerical acceptance threshold, saves it, reloads it and compares predictions. The local run required a small compatibility patch to Ruby-FANN 2.0.2; that build detail is part of the example rather than a missing prerequisite left for the reader to discover.
Start with an integration-sized problem
XOR has two binary inputs and one binary target. The targets for [0,0], [0,1], [1,0], [1,1] are 0,1,1,0. A hidden layer lets this network represent the nonlinear relationship.
There is no held-out dataset here. All four possible binary input pairs are training examples and acceptance checks. A successful fit demonstrates this library integration and serialization path; it does not establish generalization, spam-detection quality or a reason to replace a simple rule in an application.
That small scope is useful when introducing native machine-learning code into Ruby. A failure can be investigated without wondering whether a large dataset, feature pipeline or target definition changed underneath the library call.
The native build is part of reproducibility
The tested runtime is Ruby 3.3.2 on arm64 macOS. Installing the unmodified Ruby-FANN 2.0.2 gem failed under the available Clang compiler. One error concerned the Ruby VALUE passed to a void * user-data parameter. Another concerned get_neurons: its C function declared an unused extra parameter although Ruby registered a zero-argument method.
The supplied build script verifies the original gem's SHA-256, changes only those two locations and builds version 2.0.2.oneruby1. The first change makes the existing user-data cast explicit. The second removes the unused parameter to match the registered method. It does not disable compiler diagnostics or change the network's training algorithm.
This is a local compatibility build, not a published upstream release or a general certification of the extension on modern Ruby. The test suite invokes the repaired zero-argument method as well as training and persistence. The original source and documented API are available in the Ruby-FANN repository.
The README gives the exact fetch, local build and installation steps. It keeps the patched gem in a project-specific gem directory. The Gemfile and lockfile identify the tested dependencies. A C compiler is required; the upstream gem includes FANN sources, so this recipe does not require installing a separate system FANN library.
Pass the training object the API expects
The Ruby arrays are converted into RubyFann::TrainData with separate input and desired-output arrays. Training receives that object, not an array of pairs that merely resembles training data. This follows the documented Standard API.
The network has two inputs, one hidden layer with four neurons and one output. The executable uses train_epoch so it can check the same explicit acceptance condition during training:
def self.train data=RubyFann::TrainData.new(inputs: INPUTS, desired_outputs: TARGETS) # FANN initializes randomly. Bound the work and reject an unsuccessful fit. 5.times do network=RubyFann::Standard.new(num_inputs: 2, hidden_neurons: [4], num_outputs: 1) 10_000.times do |epoch| network.train_epoch(data) next unless epoch % 25 == 0 predictions=INPUTS.map { |row| predict(network,row) } mse=predictions.zip(TARGETS.flatten).sum { |p,y| (p-y)**2 } / INPUTS.length return network if mse < 0.0025 && predictions.map { |p| p >= 0.5 ? 1 : 0 } == [0,1,1,0] end end raise 'no acceptable XOR fit within five bounded attempts'endFANN initializes weights randomly. The script permits at most five attempts of ten thousand epochs each and raises if no fit meets the condition. It does not print “success” after exhausting the work budget. Exact floating-point predictions and the accepted epoch may differ across runs.
Acceptance requires both a mean squared error below 0.0025 on the four rows and the correct truth table when outputs are thresholded at 0.5. These are test settings for this fixture, not universal convergence criteria. The recorded run produced the expected classes, and the reloaded model's predictions matched within the asserted tolerance.
Check the application-facing input
The wrapper accepts exactly two finite numeric values in the interval [0,1], then converts them to floats for FANN. It rejects strings, missing or extra values, infinity, NaN, negative values and complex numbers. Ruby's permissive conversions should not decide what a model input means.
That wrapper establishes a numeric contract, not a model-quality claim for every point in the interval. Only the four binary corners are part of the XOR training check. If an application needs sensible interpolation for fractional inputs, define and evaluate that behavior separately.
Feature order is also part of an input contract. XOR happens to be symmetric, so exchanging its inputs will not reveal a column-order error. A later business dataset should include a fixture that fails when columns are swapped. The simplicity of this example is helpful, but it has that blind spot.
Verify the saved network
The complete example writes a temporary network file and reloads it with RubyFann::Standard.new(filename: ...). Each of the four predictions must agree within 1e-6. The tests also exercise training acceptance and invalid inputs. Once the local patched dependency is installed:
ruby test_example.rbThe recorded suite passes four tests and sixteen assertions. It uses temporary files and removes them afterward. Loading an arbitrary network file supplied by someone else is outside the scope; this is a round trip of the file just produced by the experiment.
A larger integration should keep the trained network together with its feature schema, preprocessing parameters, target definition and evaluation evidence. Saving weights alone does not preserve those decisions. This small Ruby experiment establishes a narrower, useful starting point: the exact native build trains the intended data, rejects malformed inputs and reloads the model it actually trained.
Found a mistake or tried a different approach?
Send Alex a note ↗