Tensor¶
The Tensor is Sorix's core data structure, analogous to NumPy arrays but with the added ability to record every operation within a computational graph. This operation tracking is what enables the automatic computation of gradients(Autograd).
# Uncomment the next line and run this cell to install sorix
#!pip install 'sorix @ git+https://github.com/Mitchell-Mirano/sorix.git@main'
import sorix
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
Create a Tensor¶
A tensor can be initialized from a NumPy array, a pandas DataFrame, or a Python list. Internally, Sorix converts any supported input into a NumPy array.
# from list
a = sorix.tensor([1,2,3])
a
tensor([1, 2, 3])
#from numpy
a = sorix.tensor(np.random.rand(5,5),dtype=sorix.float32)
a
tensor([[0.43065485, 0.42620975, 0.3720175 , 0.4976625 , 0.11820164],
[0.4388788 , 0.23018256, 0.9950211 , 0.9613169 , 0.5790351 ],
[0.5429745 , 0.6177205 , 0.71461767, 0.00832988, 0.38153866],
[0.3073224 , 0.10349764, 0.9282094 , 0.27633986, 0.70525354],
[0.7062211 , 0.76160985, 0.4015941 , 0.40373695, 0.3826829 ]])
# from pandas
data = pd.DataFrame({
'a': [0.464307, 0.182403, 0.664873, 0.906638, 0.725385],
'b': [0.278199, 0.187902, 0.887387, 0.473387, 0.904510],
'c': [0.793136, 0.957675, 0.035765, 0.639977, 0.622032],
'd': [0.618634, 0.784397, 0.841349, 0.352944, 0.783273],
'e': [0.729128, 0.467162, 0.687347, 0.432614, 0.980809]
}).to_numpy()
t = sorix.tensor(data)
t
tensor([[0.464307, 0.278199, 0.793136, 0.618634, 0.729128],
[0.182403, 0.187902, 0.957675, 0.784397, 0.467162],
[0.664873, 0.887387, 0.035765, 0.841349, 0.687347],
[0.906638, 0.473387, 0.639977, 0.352944, 0.432614],
[0.725385, 0.90451 , 0.622032, 0.783273, 0.980809]], dtype=sorix.float64)
To access the underlying NumPy array within a Sorix tensor, you can use the data attribute and apply any NumPy operation directly to it.
t.data
array([[0.464307, 0.278199, 0.793136, 0.618634, 0.729128],
[0.182403, 0.187902, 0.957675, 0.784397, 0.467162],
[0.664873, 0.887387, 0.035765, 0.841349, 0.687347],
[0.906638, 0.473387, 0.639977, 0.352944, 0.432614],
[0.725385, 0.90451 , 0.622032, 0.783273, 0.980809]])
type(t.data)
numpy.ndarray
Sorix utils to create tensors¶
t = sorix.as_tensor([1,2,3])
t
tensor([1, 2, 3])
t = sorix.randn(3,4)
t
tensor([[-0.91309799, -0.98467347, 0.85302319, 2.59460132],
[-0.74006465, -0.90355603, 0.13824669, -1.00591477],
[ 1.59923328, -0.66247029, -0.23872872, -1.47278178]], dtype=sorix.float64)
t = sorix.zeros((3,4))
t
tensor([[0., 0., 0., 0.],
[0., 0., 0., 0.],
[0., 0., 0., 0.]], dtype=sorix.float64)
t = sorix.ones((3,4))
t
tensor([[1., 1., 1., 1.],
[1., 1., 1., 1.],
[1., 1., 1., 1.]], dtype=sorix.float64)
t = sorix.eye(3)
t
tensor([[1., 0., 0.],
[0., 1., 0.],
[0., 0., 1.]], dtype=sorix.float64)
t = sorix.diag(sorix.tensor([1,2,3]))
t
tensor([[1, 0, 0],
[0, 2, 0],
[0, 0, 3]])
t = sorix.randint(0,10,(3,4))
t
tensor([[5, 8, 8, 4],
[7, 7, 6, 7],
[8, 9, 3, 7]])
t = sorix.arange(0,10)
t
tensor([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
t = sorix.linspace(0,10,5)
t
tensor([ 0. , 2.5, 5. , 7.5, 10. ], dtype=sorix.float64)
t = sorix.logspace(0,10,5)
t
tensor([1.00000000e+00, 3.16227766e+02, 1.00000000e+05, 3.16227766e+07,
1.00000000e+10], dtype=sorix.float64)
t = sorix.randperm(5)
t
tensor([1, 0, 3, 2, 4])
Basic Operations¶
a = sorix.tensor([1,2,3])
b = sorix.tensor([3,4,5])
print(a)
print(b)
tensor([1, 2, 3]) tensor([3, 4, 5])
c = a + b
c
tensor([4, 6, 8])
c = a - b
c
tensor([-2, -2, -2])
c = a * b
c
tensor([ 3, 8, 15])
c = a@b
c
tensor(26)
c = a**2
c
tensor([1, 4, 9])
In-place Operations¶
Methods with a trailing underscore modify the tensor's data in place and return the same object, instead of allocating a new tensor:
| Method | Effect |
|---|---|
t.add_(x) |
t.data += x |
t.sub_(x) |
t.data -= x |
t.mul_(x) |
t.data *= x |
t.fill_(v) |
sets every element to v |
These are useful for updating buffers and running statistics without allocating, but they
are invisible to autograd: they mutate data directly without recording a node in the
computational graph. A tensor whose value silently changed after the
forward pass would make the recorded graph describe a computation that never happened, and
backward() would produce wrong gradients.
Sorix therefore refuses the operation instead of computing a wrong answer: calling an
in-place method on a tensor with requires_grad=True while gradient tracking is active
raises RuntimeError.
a = sorix.tensor([1.0, 2.0, 3.0])
before = id(a)
a.add_(10)
print(a.data, "| same object:", id(a) == before)
a.mul_(2)
print(a.data)
a.fill_(0)
print(a.data)
[11. 12. 13.] | same object: True [22. 24. 26.] [0. 0. 0.]
On a tensor that requires gradients, the in-place call is rejected. Use the out-of-place
operator (t = t + x, which records a graph node) when you need the gradient, or
sorix.no_grad() when you deliberately want to bypass autograd ā updating parameters
inside an optimizer, for instance.
w = sorix.tensor([1.0, 2.0, 3.0], requires_grad=True)
try:
w.add_(1)
except RuntimeError as e:
print("RuntimeError:", e)
# Deliberate, gradient-free update
with sorix.no_grad():
w.add_(1)
print("\nafter no_grad update:", w.data)
RuntimeError: In-place operations are not allowed on tensors that require grad and are used in a gradient-tracked context. Use the out-of-place operator instead, or wrap the call with sorix.no_grad() if gradients are not needed. after no_grad update: [2. 3. 4.]
!!! warning "In-place operations affect pending backward passes"
The check can only inspect the tensor you call the method on. In y = a * c, the
gradient of a is computed from c.data, so mutating c after the forward pass but
before y.backward() changes a.grad ā even though c itself does not require
gradients and no error is raised. Only mutate tensors that no pending backward()
depends on.
Slicing¶
a = sorix.tensor(np.random.rand(5,5))
a
tensor([[0.82733101, 0.72921693, 0.12056758, 0.84903599, 0.66608122],
[0.64722863, 0.21587041, 0.86183826, 0.22016352, 0.89633066],
[0.65857766, 0.35433578, 0.46872752, 0.7416063 , 0.20545232],
[0.29725269, 0.36510746, 0.47547735, 0.77781916, 0.6400706 ],
[0.75096302, 0.78072124, 0.34317753, 0.43672226, 0.31110219]], dtype=sorix.float64)
a[3,:]
tensor([0.29725269, 0.36510746, 0.47547735, 0.77781916, 0.6400706 ], dtype=sorix.float64)
a[3,3]
tensor(0.77781916)
a[:,3]
tensor([0.84903599, 0.22016352, 0.7416063 , 0.77781916, 0.43672226], dtype=sorix.float64)
Using GPU¶
When running on a GPU, Sorix uses CuPy arrays instead of NumPy. You can enable GPU execution by setting the device parameter to 'cuda' (the default is 'cpu'). When 'cuda' is specified, Sorix creates CuPy-based tensors and executes all operations on the GPU.
To check whether a GPU is available, you can call sorix.cuda.is_available(). Refer to the examples below.
device = 'cuda' if sorix.cuda.is_available() else 'cpu'
device
ā GPU basic operation passed
ā GPU available: NVIDIA GeForce RTX 4070 Laptop GPU CUDA runtime version: 13000 CuPy version: 13.6.0
'cuda'
a = sorix.tensor(np.random.rand(5,5), device=device)
b = sorix.tensor(np.random.rand(5,5), device=device)
c = a + b
c
tensor([[0.79701285, 0.75183362, 1.12971127, 1.19053921, 1.68741916],
[1.35793971, 0.62530975, 0.39224349, 0.63813683, 1.0642882 ],
[1.29641472, 1.06719118, 1.159972 , 1.37261531, 0.8628912 ],
[1.01570519, 0.27668846, 0.8194673 , 1.50513293, 0.39567022],
[1.03652059, 0.83993865, 0.86620938, 1.92097398, 1.49862399]], device='cuda:0', dtype=sorix.float64)