Edit: New version on this post: LuNeT 1.1 - Neural Network library (very) inspired by PyTorch
Hello,
I’ve created a machine learning library for Lua, specifically for Roblox. It’s heavily inspired by PyTorch (or you could say, it’s a copy). My library is designed primarily for reinforcement learning, which is a type of learning based on rewards.
At the moment, I’m far from having a full PyTorch replica, but it does include basic features such as Linear layers, functions like ReLU, Sigmoid, Softmax, and Tanh, as well as autograd. I’ve also built a game to demonstrate how a model can learn. The goal of the model is to move towards the green block while avoiding the red blocks.
I highly recommend learning the basics of PyTorch first before using my library, even though it may not be very motivating to learn an entire subject for an unfinished Roblox library.
If you’re not interested in learning PyTorch basics, here’s a tutorial to get you started. This tutorial won’t explain everything about neural networks or PyTorch, but it’s a good place to begin.
Understanding How Neural Networks Work
A neural network consists mainly of 3 components:
- An input layer, where a group of values is taken into account (e.g., pixels in an image or obstacle/arrival info in a game).
- A hidden layer that processes the information. A network may have multiple hidden layers.
- An output layer that returns the processed information.
Let’s imagine a network with two layers: the first layer has 3 neurons and the second also has 3 neurons.
- The first layer has 3 neurons: Neuron A, Neuron B, Neuron C.
- The second layer has 3 neurons: Neuron X, Neuron Y, Neuron Z.
Each neuron in the second layer is connected to all neurons in the first layer. This means:
- Neuron X receives information from Neuron A, Neuron B, and Neuron C.
- Neuron Y also receives information from Neuron A, Neuron B, and Neuron C.
- Neuron Z also receives information from Neuron A, Neuron B, and Neuron C.
So, each neuron in the second layer receives all the information from the neurons in the first layer.
Each connection has an associated weight, and each neuron has a bias. When a neuron receives a value, it multiplies the value by its weight, sums the results, and adds the bias.
For example, let’s say the weights of Neuron A are [0.5, 2, 0] and its bias is 2. Neuron A receives values from Neuron X, Y, Z, such as [2, 7.3, 23]. To calculate the input for Neuron A:
(0.5×2) + (2×7.3) + (0×23) + 2 = 1 + 14.6 + 0 + 2 = 17.6
We apply this calculation for each neuron in the second layer using their weights and biases. The greater the value, the more active the neuron becomes.
After each layer, we usually apply an activation function to all values, such as:
- ReLU which transforms any negative value to 0 and keeps positive values unchanged.
- Softmax which converts a set of values into probabilities (used for classification).
- Sigmoid which converts a value between 0 and 1 (used for regression, like pixel color values in an image).
- Tanh which converts a value between -1 and 1.
Documentation
- Tensors
Now, let’s talk about Tensors or arrays. A tensor is a list with a specific shape. For example, if a tensor looks like this:
[[1, 2, 3],
[4, 5, 6],
[7, 8, 9]]
It has the shape [3, 3] because it’s a list with 3 elements, and each element itself has 3 elements. The constraint is that a tensor like:
[[1, 2, 3],
[4, 5, 6],
[7, 8, 9, 10]]
is irregular and not valid.
Tensors allow all sorts of operations, such as adding two tensors together to create a new one.
- Creating the Model
The model script should be placed in a ModuleScript to be imported later.
Here’s an example of creating a model that takes input values between 0 and 1 and outputs a value between 0 and 1. If both input values are 1, the output should be 1, and if one of them is 0, the output should be 0.
The required modules are:
- Module: a necessary module to import because our model will inherit its functions, making it trainable.
- Linear: for creating a linear layer.
local Module = require(script.Parent.torch.nn.Module)
local Linear = require(script.Parent.torch.nn.Linear)
local Sequential = require(script.Parent.torch.nn.Sequential)
local Sigmoid = require(script.Parent.torch.nn.Sigmoid)
Once the modules are imported, we need to initialize the model. To do this, add this code at the beginning of the script:
local Model = {}
Model.__index = Actor
setmetatable(Model, { __index = Module })
Then, we create a function to initialize the model. We’ll call the function Model.New()
and inside it, initialize the variable self
as Module.New()
, call the function self.init(self, Model)
, and return self
.
function Model.new()
local self = Module.new()
self.init(self, Model)
return self
end
At this point, our model doesn’t have any layers. To initialize a Linear layer, we need to call the function Linear.new(in_features, out_features, bias?)
, and then call self:add_module(module_name, the_module)
.
function Model.new()
local self = Module.new()
self.init(self, Model)
-- Takes 2 inputs and transforms them into 1 output. Bias is true by default.
self.main = Linear.new(2, 1)
self:add_module("main", self.main)
return self
end
Next, we need to create the forward(x)
function, which passes values through each layer:
function Model:forward(x)
return self.main(x)
end
At the end, our model will look like this:
local Module = require(script.Parent.torch.nn.Module)
local Linear = require(script.Parent.torch.nn.Linear)
local Model = {}
Model.__index = Model
setmetatable(Model, { __index = Module })
function Model.new()
local self = Module.new()
self.init(self, Model)
-- Sequential executes each module by passing the returned value to the next one
self.main = Sequential.new(
Linear.new(2, 2),
Linear.new(2, 1),
Sigmoid.new()
)
self:add_module("main", self.main)
return self
end
function Model:forward(x)
return self.main(x)
end
return Model
Testing the Model
In another script, we can import the model:
local Model = require(path.to.the.model)
Then, we create an instance:
local myModel = Model.new()
The tensor shape we need to provide is [batch_size, num_features], where batch_size is the number of examples the model will process, and num_features is the number of values the model will take in (for a linear network, this would be 2).
Here’s how to create a tensor with random values between 0 and 1 with the correct shape:
local torch = require(path.to.torch.torch)
local shape = {1, 2}
local x = torch.rand(shape)
For this example, we’ll create a tensor with values 1 and 0:
local x = torch.tensor({{1, 0}})
Then we pass it to the model:
local z = myModel(x) -- or myModel:forward(x)
print(z) -- random value because the weights are initialized randomly
print(z.shape) -- {1, 1}
But if we pass a tensor with shape {8, 2}, it will return a tensor with shape {8, 1} because we have 8 examples and 1 feature.
Training the Model
In the same script, we import an optimizer, which will update the weights and biases based on the Learning Rate (how much the weights and biases should change). If the learning rate is too high, the weights will be updated too quickly and might “forget” previous learning.
local optim = require(path.to.torch.optim.optim)
We also import a loss function, which calculates the difference between what the model should return and what it actually returns.
local MSELoss = require(path.to.torch.nn.MSELoss)
Now we create the optimizer and loss function:
local optimizer = optim.SGD(model:parameters(), 0.01) -- We give the model parameters and learning rate
Training Loop
Here’s an example with 100 iterations. In training, there are 4 steps:
- Forward Pass, where the model makes predictions.
- Loss Calculation, where we calculate the difference with the actual result.
- Backward Pass, where we determine which weights and biases need to be adjusted and by how much.
- Optimizer Step, where the optimizer updates the weights and biases based on the learning rate.
for _ = 1, 100 do
local a, b = math.random(0,1), math.random(0,1)
local y_input = torch.tensor({{a, b}})
local c = 0
if a == 1 and b == 1 then
c = 1
end
c = torch.tensor({c})
local y_pred = myModel(y_input)
end
Now the code compares the predictions with the actual values and adjusts the weights and biases accordingly.
At the end, the loop will look like this:
local myModel = Model.new()
local optimizer = optim.SGD(myModel:parameters(), 0.001)
local criterion = nn.MSELoss()
myModel:train() -- Let the model know it’s training
for _ = 1, 1000 do
local a, b = math.random(0,1), math.random(0,1)
local y_input = torch.tensor({{a, b}})
local y_true = 0
if a == 1 and b == 1 then
y_true = 1
end
y_true = torch.tensor({y_true})
local y_pred = myModel(y_input)
local loss = criterion(y_pred, y_true)
loss:backward()
optimizer:step()
print(loss)
end
-- Disable gradient to prevent memory overload
torch.no_grad(function()
myModel:eval() -- Let the model know it’s not training anymore
local x = torch.tensor({{1, 1}})
local z = myModel(x)
print(z) -- Should print a value very close to 1, like 0.99999
end)
Thanks for reading the documentation!
Right now, many features are missing, such as saving and importing models. Also, there are other missing mechanisms.
You can do whatever you want with the source code, though I believe once I post the code on Roblox, it no longer belongs to me.
You can use this library for make bot even if there is no saving mechanics right now
I want to clarify that this project is not affiliated with PyTorch or Meta.
I wrote this post quickly, so I didn’t explain everything thoroughly. If you want to learn more about neural networks, I recommend watching this video or this shorter one for a more concise explanation with fewer details.
To get the source code and a more complex example, you can check uncopylocked this game made by me.
If you have any questions or issues, feel free to ask!