gradix 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- gradix/__init__.py +35 -0
- gradix/init.py +33 -0
- gradix/nn/__init__.py +17 -0
- gradix/nn/functional.py +108 -0
- gradix/nn/loss/__init__.py +3 -0
- gradix/nn/loss/crossentropyloss.py +19 -0
- gradix/nn/modules/__init__.py +6 -0
- gradix/nn/modules/conv2d.py +66 -0
- gradix/nn/modules/linear.py +26 -0
- gradix/nn/modules/module.py +14 -0
- gradix/nn/modules/upsample.py +20 -0
- gradix/nn/optim/SGD.py +14 -0
- gradix/nn/optim/__init__.py +3 -0
- gradix/tensor.py +336 -0
- gradix/utils.py +34 -0
- gradix-0.1.0.dist-info/METADATA +312 -0
- gradix-0.1.0.dist-info/RECORD +20 -0
- gradix-0.1.0.dist-info/WHEEL +5 -0
- gradix-0.1.0.dist-info/licenses/LICENSE +21 -0
- gradix-0.1.0.dist-info/top_level.txt +1 -0
gradix/__init__.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
|
|
3
|
+
try:
|
|
4
|
+
import cupy as cp
|
|
5
|
+
import cupyx
|
|
6
|
+
except ImportError:
|
|
7
|
+
cp = None
|
|
8
|
+
cupyx = None
|
|
9
|
+
|
|
10
|
+
GPU_AVAILABLE = cp is not None and cp.cuda.is_available() and cp.cuda.runtime.getDeviceCount() > 0
|
|
11
|
+
DEVICE = 'cuda' if GPU_AVAILABLE else 'cpu'
|
|
12
|
+
|
|
13
|
+
#dtypes
|
|
14
|
+
float32 = np.float32
|
|
15
|
+
float64 = np.float64
|
|
16
|
+
float16 = np.float16
|
|
17
|
+
int32 = np.int32
|
|
18
|
+
int64 = np.int64
|
|
19
|
+
bool_ = np.bool_
|
|
20
|
+
|
|
21
|
+
from .tensor import Tensor
|
|
22
|
+
from . import nn
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"Tensor",
|
|
26
|
+
"nn",
|
|
27
|
+
"GPU_AVAILABLE",
|
|
28
|
+
"DEVICE",
|
|
29
|
+
"float32",
|
|
30
|
+
"float64",
|
|
31
|
+
"float16",
|
|
32
|
+
"int32",
|
|
33
|
+
"int64",
|
|
34
|
+
"bool_",
|
|
35
|
+
]
|
gradix/init.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
|
|
3
|
+
def uniform(shape, left_bound = 0.0, right_bound = 1.0, dtype = np.float32):
|
|
4
|
+
return np.random.uniform(left_bound, right_bound, size=shape).astype(dtype)
|
|
5
|
+
|
|
6
|
+
def kaiming_uniform(shape, a = np.sqrt(5.0)):
|
|
7
|
+
fan_in, fan_out = _get_fan_in_and_fan_out(shape)
|
|
8
|
+
gain = np.sqrt(2.0 / (1.0 + a ** 2))
|
|
9
|
+
|
|
10
|
+
std = gain / np.sqrt(fan_in)
|
|
11
|
+
|
|
12
|
+
weight_bound = np.sqrt(3.0) * std
|
|
13
|
+
|
|
14
|
+
return uniform(shape, -weight_bound, weight_bound)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _get_fan_in_and_fan_out(shape):
|
|
18
|
+
assert isinstance(shape, (tuple, list))
|
|
19
|
+
|
|
20
|
+
if len(shape) < 2:
|
|
21
|
+
raise ValueError('Can not implement fan in and fan out for shape less than 2')
|
|
22
|
+
fan_in = shape[1]
|
|
23
|
+
fan_out = shape[0]
|
|
24
|
+
|
|
25
|
+
receptive_field_size = 1
|
|
26
|
+
|
|
27
|
+
if len(shape) > 2:
|
|
28
|
+
for s in shape[2:]:
|
|
29
|
+
receptive_field_size *= s
|
|
30
|
+
fan_in *= receptive_field_size
|
|
31
|
+
fan_out *= receptive_field_size
|
|
32
|
+
|
|
33
|
+
return fan_in, fan_out
|
gradix/nn/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from . import functional, loss, modules, optim
|
|
2
|
+
from .loss import cross_entropy
|
|
3
|
+
from .modules import Conv2D, Linear, Module, Upsample
|
|
4
|
+
from .optim import SGD
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"functional",
|
|
8
|
+
"loss",
|
|
9
|
+
"modules",
|
|
10
|
+
"optim",
|
|
11
|
+
"Module",
|
|
12
|
+
"Linear",
|
|
13
|
+
"Conv2D",
|
|
14
|
+
"Upsample",
|
|
15
|
+
"cross_entropy",
|
|
16
|
+
"SGD",
|
|
17
|
+
]
|
gradix/nn/functional.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from gradix.tensor import Tensor
|
|
3
|
+
|
|
4
|
+
def _validate_int_tuple(*dims, func_name = ''):
|
|
5
|
+
dims = tuple(dims)
|
|
6
|
+
if len(dims) == 1 and isinstance(dims, (tuple, list)):
|
|
7
|
+
dims = tuple(dims[0])
|
|
8
|
+
|
|
9
|
+
if any((not isinstance(d, int) or d < 0) for d in dims):
|
|
10
|
+
raise ValueError('Expected Positive int in : {func_name}')
|
|
11
|
+
return dims
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def sigmoid(x: Tensor):
|
|
15
|
+
return 1 / (1 + (-x).exp())
|
|
16
|
+
|
|
17
|
+
def softmax(x: Tensor):
|
|
18
|
+
max_x = x.max()
|
|
19
|
+
x = x - max_x
|
|
20
|
+
ex = x.exp()
|
|
21
|
+
sum_ex = ex.sum()
|
|
22
|
+
return ex / sum_ex
|
|
23
|
+
|
|
24
|
+
def log_softmax(x: Tensor, axis = 1):
|
|
25
|
+
max_x = x.max(axis = axis, keepdims = True)
|
|
26
|
+
shifted = x - max_x
|
|
27
|
+
sum_ex = shifted.exp().sum(axis = axis, keepdims = True)
|
|
28
|
+
return shifted - sum_ex.log()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def im2td(x, kernel, stride = 1, padding = 0):
|
|
32
|
+
if not isinstance(x, Tensor) or x.ndim != 4:
|
|
33
|
+
raise ValueError("unfold2d expects a Tensor shaped (batch, channels, height, width)")
|
|
34
|
+
if not isinstance(kernel, int) or kernel <= 0:
|
|
35
|
+
raise ValueError("kernel_size must be a positive integer")
|
|
36
|
+
if not isinstance(stride, int) or stride <= 0:
|
|
37
|
+
raise ValueError("stride must be a positive integer")
|
|
38
|
+
if not isinstance(padding, int) or padding < 0:
|
|
39
|
+
raise ValueError("padding must be a non-negative integer")
|
|
40
|
+
|
|
41
|
+
batch_size, channels, height, width = x.shape
|
|
42
|
+
output_height = (height + 2 * padding - kernel) // stride + 1
|
|
43
|
+
output_width = (width + 2 * padding - kernel) // stride + 1
|
|
44
|
+
|
|
45
|
+
if output_height <= 0 or output_width <= 0:
|
|
46
|
+
raise ValueError("kernel_size cannot exceed the padded input dimensions")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
batch, out_y, out_x, channel, kernel_y, kernel_x = np.meshgrid(
|
|
50
|
+
np.arange(batch_size),
|
|
51
|
+
np.arange(output_height),
|
|
52
|
+
np.arange(output_width),
|
|
53
|
+
np.arange(channels),
|
|
54
|
+
np.arange(kernel),
|
|
55
|
+
np.arange(kernel),
|
|
56
|
+
indexing='ij'
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
out_y = out_y * stride + kernel_y - padding
|
|
60
|
+
out_x = out_x * stride + kernel_x - padding
|
|
61
|
+
|
|
62
|
+
valid = (out_y >= 0) & (out_x >= 0) & (out_y < height) & (out_x < width)
|
|
63
|
+
|
|
64
|
+
clipped_x = np.clip(out_x, 0, width - 1)
|
|
65
|
+
clipped_y = np.clip(out_y, 0, height - 1)
|
|
66
|
+
|
|
67
|
+
indices = ((batch * channels + channel) * height + clipped_y) * width + clipped_x
|
|
68
|
+
|
|
69
|
+
gathered_out = x.gather(indices)
|
|
70
|
+
|
|
71
|
+
out = gathered_out * Tensor(valid.astype(np.float32))
|
|
72
|
+
|
|
73
|
+
return out.reshape(
|
|
74
|
+
batch_size * output_height * output_width,
|
|
75
|
+
channels * kernel * kernel
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
def repeat(x: Tensor, *dims):
|
|
79
|
+
dims = _validate_int_tuple(dims)
|
|
80
|
+
if len(dims) < x.ndim:
|
|
81
|
+
raise ValueError(f"repeat expects at least {x.ndim} but got {len(dims)}")
|
|
82
|
+
padded_shape = (1, ) * (len(dims) - x.ndim) + x.shape
|
|
83
|
+
reshape_shape = tuple(
|
|
84
|
+
value
|
|
85
|
+
for size in padded_shape
|
|
86
|
+
for value in (1, size)
|
|
87
|
+
)
|
|
88
|
+
x = x.reshape(reshape_shape)
|
|
89
|
+
broadcast_shape = tuple(
|
|
90
|
+
value
|
|
91
|
+
for repeat, shape in zip(dims, padded_shape)
|
|
92
|
+
for value in (repeat, shape)
|
|
93
|
+
)
|
|
94
|
+
x = x.broadcast_to(broadcast_shape)
|
|
95
|
+
output_shape = tuple(repeat * size for repeat, size in zip(dims, padded_shape))
|
|
96
|
+
return x.reshape(output_shape)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def Upsample(x, scale_factor = 2):
|
|
100
|
+
assert isinstance(scale_factor, int)
|
|
101
|
+
assert isinstance(x, Tensor)
|
|
102
|
+
assert x.ndim == 4
|
|
103
|
+
|
|
104
|
+
b, c, h, w = x.shape
|
|
105
|
+
x = x.reshape(b, c, h, 1, w, 1)
|
|
106
|
+
x = repeat(x, 1, 1, 1, scale_factor, 1, scale_factor)
|
|
107
|
+
print(x.shape)
|
|
108
|
+
return x.reshape(b, c, h * scale_factor, w * scale_factor)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from gradix.nn.functional import log_softmax
|
|
3
|
+
from gradix.tensor import Tensor
|
|
4
|
+
|
|
5
|
+
def cross_entropy(logits, targets):
|
|
6
|
+
assert isinstance(logits, Tensor)
|
|
7
|
+
assert isinstance(targets, Tensor)
|
|
8
|
+
if logits.ndim != 2:
|
|
9
|
+
raise ValueError(f'cross_entropy expects logits of shape (batch, classes) but found: {logits.shape}')
|
|
10
|
+
batch_size, num_classes = logits.shape
|
|
11
|
+
class_indices = targets.data.astype(int).reshape(-1)
|
|
12
|
+
if class_indices.size != batch_size:
|
|
13
|
+
raise ValueError(f'Expected {batch_size} targets but found: {class_indices.size}')
|
|
14
|
+
if (class_indices < 0).any() or (class_indices >= num_classes).any():
|
|
15
|
+
raise IndexError(f'target class out of range [0, {num_classes})')
|
|
16
|
+
log_probs = log_softmax(logits)
|
|
17
|
+
row_offsets = num_classes * logits.xp.arange(batch_size)
|
|
18
|
+
corrected_log_probs = log_probs.gather(row_offsets + class_indices)
|
|
19
|
+
return -corrected_log_probs.mean()
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from gradix.tensor import Tensor
|
|
2
|
+
import numpy as np
|
|
3
|
+
|
|
4
|
+
from gradix.nn.modules.module import Module
|
|
5
|
+
from gradix.init import _get_fan_in_and_fan_out, kaiming_uniform, uniform
|
|
6
|
+
from gradix.nn import functional as F
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Conv2D(Module):
|
|
10
|
+
def __init__(self, in_channels, out_channels, kernel, stride = 1, padding = 0, use_bias = False):
|
|
11
|
+
super().__init__()
|
|
12
|
+
|
|
13
|
+
if not isinstance(in_channels, int) or in_channels <= 0:
|
|
14
|
+
raise ValueError("in_channels must be a positive integer")
|
|
15
|
+
if not isinstance(out_channels, int) or out_channels <= 0:
|
|
16
|
+
raise ValueError("out_channels must be a positive integer")
|
|
17
|
+
if not isinstance(kernel, int) or kernel <= 0:
|
|
18
|
+
raise ValueError("kernel_size must be a positive integer")
|
|
19
|
+
if not isinstance(stride, int) or stride <= 0:
|
|
20
|
+
raise ValueError("stride must be a positive integer")
|
|
21
|
+
if not isinstance(padding, int) or padding < 0:
|
|
22
|
+
raise ValueError("padding must be a non-negative integer")
|
|
23
|
+
|
|
24
|
+
self.in_channels = in_channels
|
|
25
|
+
self.out_channels = out_channels
|
|
26
|
+
self.kernel = kernel
|
|
27
|
+
self.stride = stride
|
|
28
|
+
self.padding = padding
|
|
29
|
+
self.bias = None
|
|
30
|
+
self.weight = Tensor(
|
|
31
|
+
kaiming_uniform((out_channels, in_channels, kernel, kernel), np.sqrt(5.0)),
|
|
32
|
+
requires_grad = True,
|
|
33
|
+
dtype = np.float32
|
|
34
|
+
)
|
|
35
|
+
if use_bias:
|
|
36
|
+
fan_in, _ = _get_fan_in_and_fan_out(self.weight.shape)
|
|
37
|
+
bound = (1.0 / np.sqrt(fan_in)) if fan_in > 0 else 0
|
|
38
|
+
self.bias = Tensor(uniform((self.out_channels, ), -bound, bound), requires_grad=True, dtype=np.float32)
|
|
39
|
+
|
|
40
|
+
def parameters(self):
|
|
41
|
+
return [self.weight] if self.bias is None else [self.weight, self.bias]
|
|
42
|
+
|
|
43
|
+
def forward(self, x):
|
|
44
|
+
if not isinstance(x, Tensor) or x.ndim != 4:
|
|
45
|
+
raise ValueError(f'Conv2D expects a tensor of shape (batch, channel, height, width) but found one of shape: {x.shape}')
|
|
46
|
+
|
|
47
|
+
batch_size, in_channels, height, width = x.shape
|
|
48
|
+
|
|
49
|
+
if in_channels != self.in_channels:
|
|
50
|
+
raise ValueError(f'Expected {self.in_channels} input channels but received: {in_channels}')
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
output_height = (height + 2 * self.padding - self.kernel) // self.stride + 1
|
|
54
|
+
output_width = (width + 2 * self.padding - self.kernel) // self.stride + 1
|
|
55
|
+
|
|
56
|
+
flattened_weight = self.weight.reshape(self.out_channels, -1)
|
|
57
|
+
patches = F.im2td(x, self.kernel, self.stride, self.padding)
|
|
58
|
+
|
|
59
|
+
product = patches @ flattened_weight.T
|
|
60
|
+
|
|
61
|
+
output = product.reshape(batch_size, output_height, output_width, self.out_channels).transpose(0, 3, 1, 2)
|
|
62
|
+
|
|
63
|
+
if self.bias is not None:
|
|
64
|
+
output = output + self.bias.reshape(1, self.out_channels, 1, 1)
|
|
65
|
+
|
|
66
|
+
return output
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from gradix.nn.modules.module import Module
|
|
2
|
+
from gradix.init import kaiming_uniform, uniform, _get_fan_in_and_fan_out
|
|
3
|
+
import numpy as np
|
|
4
|
+
from gradix.tensor import Tensor
|
|
5
|
+
|
|
6
|
+
class Linear(Module):
|
|
7
|
+
def __init__(self, in_features, out_features, bias = False):
|
|
8
|
+
super().__init__()
|
|
9
|
+
self.shape = (out_features, in_features)
|
|
10
|
+
self.weights = Tensor(kaiming_uniform(self.shape, np.sqrt(5.0)), requires_grad=True, dtype=np.float32)
|
|
11
|
+
self.bias = None
|
|
12
|
+
if bias:
|
|
13
|
+
fan_in, _ = _get_fan_in_and_fan_out(self.shape)
|
|
14
|
+
bound = 1 / np.sqrt(fan_in) if fan_in > 0 else 0
|
|
15
|
+
self.bias = Tensor(uniform((out_features, ), -bound, bound), requires_grad=True, dtype=np.float32)
|
|
16
|
+
|
|
17
|
+
def parameters(self):
|
|
18
|
+
if self.bias is not None:
|
|
19
|
+
return [self.weights, self.bias]
|
|
20
|
+
return [self.weights]
|
|
21
|
+
|
|
22
|
+
def forward(self, x):
|
|
23
|
+
out = x @ self.weights.T
|
|
24
|
+
if self.bias is not None:
|
|
25
|
+
out = out + self.bias
|
|
26
|
+
return out
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from gradix.tensor import Tensor
|
|
2
|
+
class Module:
|
|
3
|
+
def zero_grad(self):
|
|
4
|
+
for p in self.parameters():
|
|
5
|
+
p.grad = p.xp.zeros_like(p.data)
|
|
6
|
+
|
|
7
|
+
def __call__(self, *args, **kwargs):
|
|
8
|
+
return self.forward(*args, **kwargs)
|
|
9
|
+
|
|
10
|
+
def forward(self, *args, **kwargs):
|
|
11
|
+
raise NotImplementedError
|
|
12
|
+
|
|
13
|
+
def parameters(self):
|
|
14
|
+
return []
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
|
|
2
|
+
from gradix.nn.modules.module import Module
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class Upsample(Module):
|
|
6
|
+
def __init__(self, scale_factor: int = 1, mode = 'nearest'):
|
|
7
|
+
self.scale_factor = scale_factor
|
|
8
|
+
self.mode = mode
|
|
9
|
+
|
|
10
|
+
def _nearest_interpolate(self, x):
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
def _uniform_interpolate(self, x):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
def parameters(self):
|
|
17
|
+
return []
|
|
18
|
+
|
|
19
|
+
def forward(self, x):
|
|
20
|
+
return x
|
gradix/nn/optim/SGD.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
class SGD:
|
|
2
|
+
def __init__(self, parameters, lr = 0.001):
|
|
3
|
+
self.parameters = list(parameters)
|
|
4
|
+
self.lr = lr
|
|
5
|
+
|
|
6
|
+
def step(self):
|
|
7
|
+
for p in self.parameters:
|
|
8
|
+
if p.grad is not None:
|
|
9
|
+
p.data -= self.lr * p.grad
|
|
10
|
+
|
|
11
|
+
def zero_grad(self):
|
|
12
|
+
for p in self.parameters:
|
|
13
|
+
if p.grad is not None:
|
|
14
|
+
p.grad = p.xp.zeros_like(p.data)
|
gradix/tensor.py
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import math
|
|
2
|
+
import numpy as np
|
|
3
|
+
from . import *
|
|
4
|
+
|
|
5
|
+
class Tensor:
|
|
6
|
+
float32 = float32
|
|
7
|
+
float64 = float64
|
|
8
|
+
float16 = float16
|
|
9
|
+
int32 = int32
|
|
10
|
+
int64 = int64
|
|
11
|
+
bool_ = bool_
|
|
12
|
+
def __init__(self, data: np.ndarray, _childs: tuple = (), requires_grad = False, _op = None, device = DEVICE, dtype = None, name = None):
|
|
13
|
+
if device == 'cuda' and cp is None:
|
|
14
|
+
raise RuntimeError("CUDA support requires installing the 'gpu' extra")
|
|
15
|
+
self.device = DEVICE if device not in ['cpu', 'cuda'] else device
|
|
16
|
+
self.xp = cp if self.device == 'cuda' else np
|
|
17
|
+
self.dtype = dtype
|
|
18
|
+
self.requires_grad = requires_grad
|
|
19
|
+
self._init_data(data)
|
|
20
|
+
self._backward = lambda: None
|
|
21
|
+
self.grad = self.xp.zeros_like(self.data) if requires_grad else None
|
|
22
|
+
self._prevs = set(_childs)
|
|
23
|
+
self._op = _op
|
|
24
|
+
self._name = None
|
|
25
|
+
self.name = name
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def name(self):
|
|
29
|
+
return self._name
|
|
30
|
+
|
|
31
|
+
@name.setter
|
|
32
|
+
def name(self, value):
|
|
33
|
+
self._name = None if value is None else str(value)
|
|
34
|
+
|
|
35
|
+
def _init_data(self, data):
|
|
36
|
+
if self.dtype is None:
|
|
37
|
+
self.dtype = getattr(data, 'dtype', self.float32)
|
|
38
|
+
if self.requires_grad and np.dtype(self.dtype).kind != 'f':
|
|
39
|
+
self.dtype = self.float32
|
|
40
|
+
if isinstance(data, self.xp.ndarray):
|
|
41
|
+
self.data = data.astype(self.dtype, copy = False)
|
|
42
|
+
else:
|
|
43
|
+
self.data = self.xp.asarray(data, dtype = self.dtype)
|
|
44
|
+
|
|
45
|
+
def _scatter_add(self, target, key, values):
|
|
46
|
+
if self.device == 'cuda':
|
|
47
|
+
if cupyx is None:
|
|
48
|
+
raise RuntimeError("CUDA support requires installing the 'gpu' extra")
|
|
49
|
+
cupyx.scatter_add(target, key, values)
|
|
50
|
+
return
|
|
51
|
+
np.add.at(target, key, values)
|
|
52
|
+
|
|
53
|
+
def _const(self, other):
|
|
54
|
+
if isinstance(other, Tensor):
|
|
55
|
+
return other
|
|
56
|
+
return Tensor(other, device = self.device, dtype = self.dtype)
|
|
57
|
+
|
|
58
|
+
def _norm_axes(self, axis):
|
|
59
|
+
if axis is None:
|
|
60
|
+
return tuple(range(self.ndim))
|
|
61
|
+
axes = axis if isinstance(axis, tuple) else (axis,)
|
|
62
|
+
return tuple(ax % self.ndim for ax in axes)
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def T(self):
|
|
66
|
+
return self.transpose()
|
|
67
|
+
|
|
68
|
+
def transpose(self, *axes):
|
|
69
|
+
if not axes:
|
|
70
|
+
axes = tuple(reversed(range(self.data.ndim)))
|
|
71
|
+
elif len(axes) == 1 and isinstance(axes[0], (tuple, list)):
|
|
72
|
+
axes = tuple(axes[0])
|
|
73
|
+
else:
|
|
74
|
+
axes = tuple(axes)
|
|
75
|
+
axes = tuple(ax % self.ndim for ax in axes)
|
|
76
|
+
out = Tensor(self.xp.transpose(self.data, axes), (self,), self.requires_grad, _op='transpose', device = self.device)
|
|
77
|
+
def _backward():
|
|
78
|
+
inverse_axes = tuple(int(i) for i in np.argsort(axes))
|
|
79
|
+
self._accumulate_grad(self.xp.transpose(out.grad, inverse_axes))
|
|
80
|
+
out._backward = _backward
|
|
81
|
+
return out
|
|
82
|
+
|
|
83
|
+
def reshape(self, *shape):
|
|
84
|
+
if len(shape) == 1 and isinstance(shape[0], (tuple, list)):
|
|
85
|
+
shape = tuple(shape[0])
|
|
86
|
+
out = Tensor(self.data.reshape(*shape), (self,), self.requires_grad, _op='reshape', device = self.device)
|
|
87
|
+
def _backward():
|
|
88
|
+
self._accumulate_grad(out.grad.reshape(self.shape))
|
|
89
|
+
out._backward = _backward
|
|
90
|
+
return out
|
|
91
|
+
|
|
92
|
+
def gather(self, indices):
|
|
93
|
+
indices = self.xp.asarray(indices)
|
|
94
|
+
if not np.issubdtype(indices.dtype, np.integer):
|
|
95
|
+
raise TypeError("gather indices must be integers")
|
|
96
|
+
indices = indices.astype(self.xp.intp, copy=False)
|
|
97
|
+
if indices.size and (int(indices.min()) < 0 or int(indices.max()) >= self.data.size):
|
|
98
|
+
raise IndexError("gather index is outside the flattened Tensor")
|
|
99
|
+
|
|
100
|
+
out = Tensor(self.data.reshape(-1)[indices], (self,), self.requires_grad, _op='gather', device = self.device)
|
|
101
|
+
|
|
102
|
+
def _backward():
|
|
103
|
+
self._scatter_add(self.grad.reshape(-1), indices.reshape(-1), out.grad.reshape(-1))
|
|
104
|
+
|
|
105
|
+
out._backward = _backward
|
|
106
|
+
return out
|
|
107
|
+
|
|
108
|
+
def broadcast_to(self, shape: tuple):
|
|
109
|
+
if len(shape) < self.ndim:
|
|
110
|
+
raise ValueError(f"Can not broadcast to fewer dimension from {self.ndim} to {len(shape)}")
|
|
111
|
+
|
|
112
|
+
for source, target in zip((1,) * (len(shape) - self.ndim) + self.shape, shape):
|
|
113
|
+
if source != target and source != 1:
|
|
114
|
+
raise ValueError(f"Can not Broadcast shape: from {self.shape} to {shape}")
|
|
115
|
+
|
|
116
|
+
out = Tensor(self.xp.broadcast_to(self.data, shape), (self, ), requires_grad= self.requires_grad, _op ='broadcast_to', device = self.device)
|
|
117
|
+
def _backward():
|
|
118
|
+
self._accumulate_grad(out.grad)
|
|
119
|
+
out._backward = _backward
|
|
120
|
+
return out
|
|
121
|
+
|
|
122
|
+
def __getitem__(self, key):
|
|
123
|
+
out = Tensor(self.data[key], (self, ), requires_grad= self.requires_grad, _op='getitem', device = self.device)
|
|
124
|
+
def _backward():
|
|
125
|
+
self._scatter_add(self.grad, key, out.grad)
|
|
126
|
+
out._backward = _backward
|
|
127
|
+
return out
|
|
128
|
+
|
|
129
|
+
def requires_grad_(self, requires_grad: bool):
|
|
130
|
+
self.requires_grad = requires_grad
|
|
131
|
+
self.grad = self.xp.zeros_like(self.data) if self.requires_grad else None
|
|
132
|
+
return self
|
|
133
|
+
|
|
134
|
+
def __add__(self, other):
|
|
135
|
+
other = self._const(other)
|
|
136
|
+
requires_grad = self.requires_grad or other.requires_grad
|
|
137
|
+
out = Tensor(self.data + other.data, (self, other), requires_grad=requires_grad, _op='+', device = self.device)
|
|
138
|
+
def _backward():
|
|
139
|
+
self._accumulate_grad(out.grad)
|
|
140
|
+
other._accumulate_grad(out.grad)
|
|
141
|
+
out._backward = _backward
|
|
142
|
+
return out
|
|
143
|
+
|
|
144
|
+
def __mul__(self, other):
|
|
145
|
+
other = self._const(other)
|
|
146
|
+
requires_grad = self.requires_grad or other.requires_grad
|
|
147
|
+
out = Tensor(self.data * other.data, (self, other), requires_grad=requires_grad, _op='*', device = self.device)
|
|
148
|
+
def _backward():
|
|
149
|
+
self._accumulate_grad(other.data * out.grad)
|
|
150
|
+
other._accumulate_grad(self.data * out.grad)
|
|
151
|
+
out._backward = _backward
|
|
152
|
+
return out
|
|
153
|
+
|
|
154
|
+
def __matmul__(self, other):
|
|
155
|
+
other = self._const(other)
|
|
156
|
+
requires_grad = self.requires_grad or other.requires_grad
|
|
157
|
+
out = Tensor(self.data @ other.data, (self, other), requires_grad=requires_grad, _op='@', device = self.device)
|
|
158
|
+
def _backward():
|
|
159
|
+
a, b, grad = self.data, other.data, out.grad
|
|
160
|
+
if b.ndim == 1:
|
|
161
|
+
b, grad = b[:, None], self.xp.expand_dims(grad, -1)
|
|
162
|
+
if a.ndim == 1:
|
|
163
|
+
a, grad = a[None, :], self.xp.expand_dims(grad, -2)
|
|
164
|
+
grad_a = grad @ b.swapaxes(-1, -2)
|
|
165
|
+
grad_b = a.swapaxes(-1, -2) @ grad
|
|
166
|
+
if self.ndim == 1:
|
|
167
|
+
grad_a = grad_a[..., 0, :]
|
|
168
|
+
if other.ndim == 1:
|
|
169
|
+
grad_b = grad_b[..., 0]
|
|
170
|
+
self._accumulate_grad(grad_a)
|
|
171
|
+
other._accumulate_grad(grad_b)
|
|
172
|
+
out._backward = _backward
|
|
173
|
+
return out
|
|
174
|
+
|
|
175
|
+
def __pow__(self, value):
|
|
176
|
+
assert isinstance(value, (int, float))
|
|
177
|
+
out = Tensor(self.xp.power(self.data, value), (self, ), requires_grad = self.requires_grad, _op=f'^{value}', device = self.device)
|
|
178
|
+
def _backward():
|
|
179
|
+
self._accumulate_grad(value * self.xp.power(self.data, value - 1) * out.grad)
|
|
180
|
+
out._backward = _backward
|
|
181
|
+
return out
|
|
182
|
+
|
|
183
|
+
def reciprocal(self):
|
|
184
|
+
out = Tensor(1 / self.data, (self, ), requires_grad=self.requires_grad, _op='reciprocal', device = self.device)
|
|
185
|
+
def _backward():
|
|
186
|
+
self._accumulate_grad(-1 * out.grad / (self.data ** 2))
|
|
187
|
+
out._backward = _backward
|
|
188
|
+
return out
|
|
189
|
+
|
|
190
|
+
def __div__(self, other):
|
|
191
|
+
other = self._const(other)
|
|
192
|
+
return self * other.reciprocal()
|
|
193
|
+
|
|
194
|
+
def __truediv__(self, other):
|
|
195
|
+
return self.__div__(other)
|
|
196
|
+
|
|
197
|
+
def leaky_relu(self, lvalue = 0.1):
|
|
198
|
+
cdata = self.data.copy()
|
|
199
|
+
lindexes = self.data <= 0
|
|
200
|
+
cdata[lindexes] *= lvalue
|
|
201
|
+
out = Tensor(cdata, (self, ), requires_grad=self.requires_grad, _op=f'leaky_relu: {lvalue}', device = self.device)
|
|
202
|
+
def _backward():
|
|
203
|
+
grad = self.xp.ones_like(self.data)
|
|
204
|
+
grad[self.data <= 0] = lvalue
|
|
205
|
+
self._accumulate_grad(out.grad * grad)
|
|
206
|
+
out._backward = _backward
|
|
207
|
+
return out
|
|
208
|
+
|
|
209
|
+
def relu(self):
|
|
210
|
+
return self.leaky_relu(0)
|
|
211
|
+
|
|
212
|
+
def exp(self):
|
|
213
|
+
out = Tensor(self.xp.exp(self.data), (self,), requires_grad=self.requires_grad, _op='exp', device = self.device)
|
|
214
|
+
def _backward():
|
|
215
|
+
self._accumulate_grad(out.grad * out.data)
|
|
216
|
+
out._backward = _backward
|
|
217
|
+
return out
|
|
218
|
+
|
|
219
|
+
def log(self):
|
|
220
|
+
out = Tensor(self.xp.log(self.data), (self, ), requires_grad = self.requires_grad, _op='log', device = self.device)
|
|
221
|
+
def _backward():
|
|
222
|
+
self._accumulate_grad(out.grad / self.data)
|
|
223
|
+
out._backward = _backward
|
|
224
|
+
return out
|
|
225
|
+
|
|
226
|
+
def tanh(self):
|
|
227
|
+
out = Tensor(self.xp.tanh(self.data), (self, ), requires_grad= self.requires_grad, _op='tanh', device = self.device)
|
|
228
|
+
def _backward():
|
|
229
|
+
self._accumulate_grad(out.grad * (1 - out.data ** 2))
|
|
230
|
+
out._backward = _backward
|
|
231
|
+
return out
|
|
232
|
+
|
|
233
|
+
def sigmoid(self):
|
|
234
|
+
out = Tensor(0.5 * (1 + self.xp.tanh(self.data * 0.5)), (self, ), requires_grad= self.requires_grad, _op='sigmoid', device = self.device)
|
|
235
|
+
def _backward():
|
|
236
|
+
self._accumulate_grad(out.grad * out.data * (1 - out.data))
|
|
237
|
+
out._backward = _backward
|
|
238
|
+
return out
|
|
239
|
+
|
|
240
|
+
def _extremum(self, fn, axis, keepdims, _op):
|
|
241
|
+
out = Tensor(fn(self.data, axis = axis, keepdims = keepdims), (self, ), requires_grad=self.requires_grad, _op=_op, device = self.device)
|
|
242
|
+
def _backward():
|
|
243
|
+
kept = fn(self.data, axis = axis, keepdims = True)
|
|
244
|
+
grad = out.grad.reshape(kept.shape)
|
|
245
|
+
mask = (self.data == kept).astype(self.data.dtype)
|
|
246
|
+
mask = mask / mask.sum(axis = axis, keepdims = True)
|
|
247
|
+
self._accumulate_grad(grad * mask)
|
|
248
|
+
out._backward = _backward
|
|
249
|
+
return out
|
|
250
|
+
|
|
251
|
+
def max(self, axis = None, keepdims = False):
|
|
252
|
+
return self._extremum(self.xp.max, axis, keepdims, 'max')
|
|
253
|
+
|
|
254
|
+
def min(self, axis = None, keepdims = False):
|
|
255
|
+
return self._extremum(self.xp.min, axis, keepdims, 'min')
|
|
256
|
+
|
|
257
|
+
def sum(self, axis = None, keepdims = False):
|
|
258
|
+
out = Tensor(self.xp.sum( self.data, axis = axis, keepdims = keepdims),(self,),self.requires_grad, _op='sum', device = self.device)
|
|
259
|
+
|
|
260
|
+
def _backward():
|
|
261
|
+
axes = self._norm_axes(axis)
|
|
262
|
+
kept_shape = tuple(1 if i in axes else s for i, s in enumerate(self.shape))
|
|
263
|
+
grad = out.grad.reshape(kept_shape)
|
|
264
|
+
self._accumulate_grad(self.xp.broadcast_to(grad, self.shape))
|
|
265
|
+
|
|
266
|
+
out._backward = _backward
|
|
267
|
+
return out
|
|
268
|
+
|
|
269
|
+
def mean(self, axis = None, keepdims = False):
|
|
270
|
+
count = math.prod(self.shape[ax] for ax in self._norm_axes(axis))
|
|
271
|
+
return self.sum(axis = axis, keepdims = keepdims) / count
|
|
272
|
+
|
|
273
|
+
def __neg__(self):
|
|
274
|
+
return self * -1
|
|
275
|
+
def __sub__(self, other):
|
|
276
|
+
return self + (-other)
|
|
277
|
+
def __radd__(self, other):
|
|
278
|
+
return self + other
|
|
279
|
+
def __rsub__(self, other):
|
|
280
|
+
return (-self) + other
|
|
281
|
+
def __rmul__(self, other):
|
|
282
|
+
return self * other
|
|
283
|
+
def __rtruediv__(self, other):
|
|
284
|
+
return other * self.reciprocal()
|
|
285
|
+
|
|
286
|
+
def zero_grad(self):
|
|
287
|
+
for node in self._build_topo():
|
|
288
|
+
if node.requires_grad:
|
|
289
|
+
node.grad = self.xp.zeros_like(node.data)
|
|
290
|
+
|
|
291
|
+
def backward(self):
|
|
292
|
+
if not self.requires_grad:
|
|
293
|
+
raise RuntimeError("backward() called on a Tensor that does not require grad")
|
|
294
|
+
nodes = list(self._build_topo())
|
|
295
|
+
for node in nodes:
|
|
296
|
+
if node._prevs:
|
|
297
|
+
node.grad = self.xp.zeros_like(node.data)
|
|
298
|
+
self.grad = self.xp.ones_like(self.data)
|
|
299
|
+
for node in nodes:
|
|
300
|
+
node._backward()
|
|
301
|
+
|
|
302
|
+
def _build_topo(self):
|
|
303
|
+
nodes, visited = [], set()
|
|
304
|
+
def build_topo(node):
|
|
305
|
+
if (node not in visited) and (node.requires_grad):
|
|
306
|
+
visited.add(node)
|
|
307
|
+
for child in node._prevs:
|
|
308
|
+
build_topo(child)
|
|
309
|
+
nodes.append(node)
|
|
310
|
+
build_topo(self)
|
|
311
|
+
return reversed(nodes)
|
|
312
|
+
|
|
313
|
+
def _accumulate_grad(self, grad):
|
|
314
|
+
if not self.requires_grad:
|
|
315
|
+
return
|
|
316
|
+
while grad.ndim > self.grad.ndim:
|
|
317
|
+
grad = grad.sum(axis=0)
|
|
318
|
+
axes = tuple(
|
|
319
|
+
i
|
|
320
|
+
for i, (gdim, tdim) in enumerate(zip(grad.shape, self.grad.shape))
|
|
321
|
+
if tdim == 1 and gdim != 1
|
|
322
|
+
)
|
|
323
|
+
if axes:
|
|
324
|
+
grad = grad.sum(axis=axes, keepdims=True)
|
|
325
|
+
self.grad += grad
|
|
326
|
+
|
|
327
|
+
@property
|
|
328
|
+
def shape(self):
|
|
329
|
+
return self.data.shape
|
|
330
|
+
|
|
331
|
+
@property
|
|
332
|
+
def ndim(self):
|
|
333
|
+
return self.data.ndim
|
|
334
|
+
|
|
335
|
+
def __repr__(self):
|
|
336
|
+
return f'Tensor({self.data}, dtype: {self.data.dtype}, Shape: {list(self.data.shape)})'
|
gradix/utils.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from graphviz import Digraph
|
|
2
|
+
|
|
3
|
+
def visualize_computation_graph(output_tensor, filename='computation_graph', format='png', view = True, c_view_options = []):
|
|
4
|
+
dot = Digraph(format=format, graph_attr={'rankdir': 'LR'}, )
|
|
5
|
+
visited = set()
|
|
6
|
+
|
|
7
|
+
def add_nodes(tensor):
|
|
8
|
+
if tensor in visited:
|
|
9
|
+
return
|
|
10
|
+
visited.add(tensor)
|
|
11
|
+
show_data = ('data' in c_view_options) and tensor.ndim == 0
|
|
12
|
+
show_grad = ('grad' in c_view_options) and tensor.ndim == 0
|
|
13
|
+
tensor_label = f'Tensor: {tensor.name}' if tensor.name is not None else 'Tensor'
|
|
14
|
+
label = f'{tensor_label}\n shape: {tensor.shape}'
|
|
15
|
+
if show_data:
|
|
16
|
+
label += f'\n data: {tensor.data}'
|
|
17
|
+
if show_grad:
|
|
18
|
+
label += f'\n grad: {tensor.grad}'
|
|
19
|
+
dot.node(str(id(tensor)), label=label, shape='box')
|
|
20
|
+
if hasattr(tensor, '_op') and tensor._op:
|
|
21
|
+
dot.node(str(id(tensor)) + '_op', label=f'Op: {tensor._op}', shape='ellipse')
|
|
22
|
+
dot.edge(str(id(tensor)) + '_op', str(id(tensor)))
|
|
23
|
+
|
|
24
|
+
for parent in tensor._prevs:
|
|
25
|
+
add_nodes(parent)
|
|
26
|
+
if hasattr(tensor, '_op') and tensor._op:
|
|
27
|
+
dot.edge(str(id(parent)), str(id(tensor)) + '_op')
|
|
28
|
+
else:
|
|
29
|
+
dot.edge(str(id(parent)), str(id(tensor)))
|
|
30
|
+
|
|
31
|
+
add_nodes(output_tensor)
|
|
32
|
+
if view is False:
|
|
33
|
+
dot.render(filename, view=view)
|
|
34
|
+
return dot
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: gradix
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A lightweight NumPy-based automatic differentiation and deep learning library
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Dist: numpy>=1.24
|
|
10
|
+
Requires-Dist: graphviz>=0.20
|
|
11
|
+
Requires-Dist: matplotlib>=3.8
|
|
12
|
+
Provides-Extra: gpu
|
|
13
|
+
Requires-Dist: cupy-cuda12x>=12; extra == "gpu"
|
|
14
|
+
Provides-Extra: all
|
|
15
|
+
Requires-Dist: cupy-cuda12x>=12; extra == "all"
|
|
16
|
+
Requires-Dist: graphviz>=0.20; extra == "all"
|
|
17
|
+
Requires-Dist: matplotlib>=3.8; extra == "all"
|
|
18
|
+
Dynamic: license-file
|
|
19
|
+
|
|
20
|
+
# Gradix
|
|
21
|
+
|
|
22
|
+
Gradix is a small, educational deep-learning engine built around a `Tensor` class and automatic differentiation. It lets you compose tensor operations, build neural-network layers, calculate gradients with `backward()`, and update parameters with SGD.
|
|
23
|
+
|
|
24
|
+
The project is intentionally compact and is useful for learning how computation graphs and training loops fit together. It is not a drop-in replacement for PyTorch or another production deep-learning framework.
|
|
25
|
+
|
|
26
|
+
## What You Can Do
|
|
27
|
+
|
|
28
|
+
- Create tensors and combine them with arithmetic, matrix multiplication, reductions, and activations.
|
|
29
|
+
- Track operations in a computation graph and calculate gradients with backpropagation.
|
|
30
|
+
- Build models with `Linear` and `Conv2D` modules.
|
|
31
|
+
- Calculate classification loss with `cross_entropy` and update parameters with `SGD`.
|
|
32
|
+
- Visualize a tensor's computation graph with Graphviz.
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
Gradix requires Python 3.10 or newer. Install the published package and its required Python dependencies with:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install gradix
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
The base installation includes NumPy, the Python Graphviz binding, and Matplotlib. Graph rendering also requires the Graphviz system executable (`dot`), which must be installed separately through your operating system's package manager.
|
|
43
|
+
|
|
44
|
+
For example, on Ubuntu or Debian:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
sudo apt install graphviz
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
CuPy is optional. The `gpu` extra currently requests the CUDA 12.x CuPy build:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
pip install "gradix[gpu]"
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
You also need a compatible NVIDIA driver and CUDA setup. CPU usage is the recommended path for the current version; CUDA support is experimental, and some operations, particularly Conv2D's patch-index creation, still use NumPy internally.
|
|
57
|
+
|
|
58
|
+
### Install From a Local Checkout
|
|
59
|
+
|
|
60
|
+
When working in a clone of this repository, install the current source in editable mode:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
python -m pip install -e .
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
This makes `import gradix` work from that Python environment without adding `src` to `sys.path` manually. In Jupyter, choose the same environment as the notebook kernel.
|
|
67
|
+
|
|
68
|
+
## Your First Tensor
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from gradix import Tensor
|
|
72
|
+
|
|
73
|
+
left = Tensor([1.0, 2.0, 3.0], requires_grad=True, name="left input")
|
|
74
|
+
right = Tensor([4.0, 5.0, 6.0], requires_grad=True, name="right input")
|
|
75
|
+
|
|
76
|
+
result = (left * right).sum()
|
|
77
|
+
result.backward()
|
|
78
|
+
|
|
79
|
+
print(result.data) # 32.0
|
|
80
|
+
print(left.grad) # [4. 5. 6.]
|
|
81
|
+
print(right.grad) # [1. 2. 3.]
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
`requires_grad=True` asks Gradix to track operations that contribute to that tensor. Calling `backward()` on a result propagates its gradient backward through those operations. Gradients are stored in each participating tensor's `.grad` attribute.
|
|
85
|
+
|
|
86
|
+
Tensor names can be assigned at construction or afterward. Any non-`None` value is converted to a string:
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
image = Tensor([0.0, 1.0], name=1)
|
|
90
|
+
image.name = 2.5
|
|
91
|
+
assert image.name == "2.5"
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Public Imports
|
|
95
|
+
|
|
96
|
+
Gradix re-exports common classes at convenient package levels:
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
from gradix import Tensor
|
|
100
|
+
from gradix.nn import Linear, Conv2D, cross_entropy, SGD
|
|
101
|
+
from gradix.nn.modules import Module, Linear, Conv2D, Upsample
|
|
102
|
+
from gradix.nn.loss import cross_entropy
|
|
103
|
+
from gradix.nn.optim import SGD
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## How Backpropagation Flows
|
|
107
|
+
|
|
108
|
+
Each operation creates a result tensor that remembers its input tensors and a local backward function. For example, matrix multiplication records its operands. When `backward()` is called, Gradix traverses the graph and applies those local derivatives from the output toward the inputs.
|
|
109
|
+
|
|
110
|
+
```mermaid
|
|
111
|
+
flowchart LR
|
|
112
|
+
A[Input a] --> M[Multiply]
|
|
113
|
+
B[Input b] --> M
|
|
114
|
+
M --> S[Sum]
|
|
115
|
+
S --> L[Scalar loss]
|
|
116
|
+
L -->|backward| S
|
|
117
|
+
S --> M
|
|
118
|
+
M --> GA[Gradient for a]
|
|
119
|
+
M --> GB[Gradient for b]
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
The core idea in code is:
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
prediction = model(batch)
|
|
126
|
+
loss = cross_entropy(prediction, targets)
|
|
127
|
+
optimizer.zero_grad()
|
|
128
|
+
loss.backward()
|
|
129
|
+
optimizer.step()
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
`zero_grad()` clears gradients from the previous batch. `backward()` calculates gradients for this batch. `step()` uses those gradients to update parameters.
|
|
133
|
+
|
|
134
|
+
## Visualize a Computation Graph
|
|
135
|
+
|
|
136
|
+
The graph examples in `demos/digraph_test.ipynb` build longer expressions and ask Graphviz to display their operation dependencies. Here is a smaller version:
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
from gradix import Tensor
|
|
140
|
+
from gradix.utils import visualize_computation_graph
|
|
141
|
+
|
|
142
|
+
a = Tensor(1.0, requires_grad=True, name="a")
|
|
143
|
+
b = Tensor(2.0, requires_grad=True, name="b")
|
|
144
|
+
c = Tensor(3.0, requires_grad=True, name="c")
|
|
145
|
+
|
|
146
|
+
result = (a + b) * c
|
|
147
|
+
result.backward()
|
|
148
|
+
|
|
149
|
+
graph = visualize_computation_graph(
|
|
150
|
+
result,
|
|
151
|
+
filename="example_graph",
|
|
152
|
+
c_view_options=["data", "grad"],
|
|
153
|
+
)
|
|
154
|
+
graph
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Each box represents a tensor and each ellipse represents an operation. Edges connect an operation to its result and its inputs. Tensor names, shapes, scalar data, and scalar gradients can help identify nodes. Graphviz's `dot` executable must be installed for rendering.
|
|
158
|
+
|
|
159
|
+
The attention-style example in `demos/digraph_test.ipynb` follows this flow:
|
|
160
|
+
|
|
161
|
+
1. Create input and weight tensors for query, key, value, and projection operations.
|
|
162
|
+
2. Compute query-key scores with matrix multiplication and a transpose.
|
|
163
|
+
3. Apply a numerically stabilized softmax using max, exponentiation, and sum.
|
|
164
|
+
4. Multiply attention weights by values, then apply a projection.
|
|
165
|
+
5. Add a residual connection and normalize using mean and variance operations.
|
|
166
|
+
6. Pass through a small MLP, combine the residual output, reduce to a scalar loss, and visualize the full graph.
|
|
167
|
+
|
|
168
|
+
The notebook also builds a recurrent, gated sequence example. Each time-step appends more operations to the same graph through the hidden state; reducing the final hidden state to a scalar and visualizing it shows how dependencies span time steps.
|
|
169
|
+
|
|
170
|
+
`demos/demo.ipynb` contains a smaller arithmetic graph. The corresponding examples are notebooks, so run cells from top to bottom after installing Gradix and Graphviz.
|
|
171
|
+
|
|
172
|
+
## Define a Model
|
|
173
|
+
|
|
174
|
+
Models subclass `Module`, implement `forward`, and return all trainable tensors from `parameters`. The `Linear` layer accepts a batch whose last dimension matches `in_features`.
|
|
175
|
+
|
|
176
|
+
```python
|
|
177
|
+
from gradix.nn.modules import Linear, Module
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class SmallClassifier(Module):
|
|
181
|
+
def __init__(self):
|
|
182
|
+
self.hidden = Linear(784, 128, bias=True)
|
|
183
|
+
self.output = Linear(128, 10, bias=True)
|
|
184
|
+
|
|
185
|
+
def parameters(self):
|
|
186
|
+
return self.hidden.parameters() + self.output.parameters()
|
|
187
|
+
|
|
188
|
+
def forward(self, x):
|
|
189
|
+
x = x.reshape(x.shape[0], 784)
|
|
190
|
+
x = self.hidden(x).relu()
|
|
191
|
+
return self.output(x)
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
The MNIST examples in `demos/MnistModel.py` show two model shapes:
|
|
195
|
+
|
|
196
|
+
- `MNISTModel`: flatten each 28-by-28 image, apply a 784-to-128 linear layer with ReLU, then produce 10 class scores.
|
|
197
|
+
- `MNISTConvModel`: reshape to `(batch, 1, 28, 28)`, apply three Conv2D layers with ReLU, flatten the result, and produce 10 class scores.
|
|
198
|
+
|
|
199
|
+
The convolutional model's `parameters()` method combines the parameters from each child layer. Gradix modules do not currently automatically register nested modules, so a custom model should explicitly return its parameters as above.
|
|
200
|
+
|
|
201
|
+
## Train a Model
|
|
202
|
+
|
|
203
|
+
Gradix's `cross_entropy` expects logits shaped `(batch, classes)` and integer class labels in a target tensor. The targets do not need gradients. This self-contained example demonstrates the same batch flow used in `demos/train.ipynb` without downloading a dataset:
|
|
204
|
+
|
|
205
|
+
```python
|
|
206
|
+
import numpy as np
|
|
207
|
+
|
|
208
|
+
from gradix import Tensor
|
|
209
|
+
from gradix.nn.loss import cross_entropy
|
|
210
|
+
from gradix.nn.modules import Linear, Module
|
|
211
|
+
from gradix.nn.optim import SGD
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
class TinyClassifier(Module):
|
|
215
|
+
def __init__(self):
|
|
216
|
+
self.layer = Linear(2, 2, bias=True)
|
|
217
|
+
|
|
218
|
+
def parameters(self):
|
|
219
|
+
return self.layer.parameters()
|
|
220
|
+
|
|
221
|
+
def forward(self, x):
|
|
222
|
+
return self.layer(x)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
x = Tensor(np.array([[1, 0], [0, 1], [1, 1], [0, 0]], dtype=np.float32))
|
|
226
|
+
targets = Tensor(np.array([0, 1, 0, 1]))
|
|
227
|
+
|
|
228
|
+
model = TinyClassifier()
|
|
229
|
+
optimizer = SGD(model.parameters(), lr=0.1)
|
|
230
|
+
|
|
231
|
+
for step in range(20):
|
|
232
|
+
logits = model(x)
|
|
233
|
+
loss = cross_entropy(logits, targets)
|
|
234
|
+
|
|
235
|
+
optimizer.zero_grad()
|
|
236
|
+
loss.backward()
|
|
237
|
+
optimizer.step()
|
|
238
|
+
|
|
239
|
+
print(f"step {step + 1}: loss={float(loss.data):.4f}")
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
In each step, the model produces one score per class, cross-entropy compares those scores with the labels, and backpropagation computes parameter gradients. SGD then updates the parameters. This is a minimal educational loop; it does not include features such as batching helpers, schedulers, regularization, checkpointing, or automatic device placement.
|
|
243
|
+
|
|
244
|
+
## MNIST Notebook Workflow
|
|
245
|
+
|
|
246
|
+
`demos/train.ipynb` demonstrates the larger workflow:
|
|
247
|
+
|
|
248
|
+
1. Load MNIST with scikit-learn's `fetch_openml` and split pixels and labels into training, validation, and test sets.
|
|
249
|
+
2. Convert pixel arrays to `float32`, scale pixels into the 0-to-1 range, and convert labels to integers.
|
|
250
|
+
3. Create `MNISTModel` or `MNISTConvModel`, then pass `model.parameters()` to `SGD`.
|
|
251
|
+
4. Iterate over mini-batches. For each batch, create input and target tensors, run the model, calculate cross-entropy, clear gradients, call `backward()`, and update parameters.
|
|
252
|
+
5. Calculate validation loss without calling `backward()`, then evaluate on the test data after training.
|
|
253
|
+
6. Plot training and validation loss with Matplotlib and optionally visualize the model's graph.
|
|
254
|
+
|
|
255
|
+
The notebook uses additional packages for the example workflow. Install them in the notebook's Python environment if they are not already present:
|
|
256
|
+
|
|
257
|
+
```bash
|
|
258
|
+
python -m pip install scikit-learn tqdm
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
Matplotlib is already a required Gradix dependency. Fetching MNIST requires an internet connection the first time. Training the full dataset, particularly with the pure-Python Conv2D implementation, may take a while.
|
|
262
|
+
|
|
263
|
+
## API at a Glance
|
|
264
|
+
|
|
265
|
+
| API | Purpose |
|
|
266
|
+
| --- | --- |
|
|
267
|
+
| `Tensor(data, requires_grad=False, name=None)` | Store array data and optionally track gradients. |
|
|
268
|
+
| `tensor.backward()` | Compute gradients from a scalar result back through its graph. |
|
|
269
|
+
| `tensor.zero_grad()` | Clear gradients in the tensor's graph. |
|
|
270
|
+
| `Module` | Base class for models with `forward()` and `parameters()`. |
|
|
271
|
+
| `Linear(in_features, out_features, bias=False)` | Fully connected transformation. |
|
|
272
|
+
| `Conv2D(in_channels, out_channels, kernel, stride=1, padding=0, use_bias=False)` | Two-dimensional convolution. |
|
|
273
|
+
| `cross_entropy(logits, targets)` | Mean multiclass cross-entropy for integer class labels. |
|
|
274
|
+
| `SGD(parameters, lr=0.001)` | Basic stochastic gradient descent optimizer. |
|
|
275
|
+
| `visualize_computation_graph(tensor, ...)` | Return or render the tensor's operation graph. |
|
|
276
|
+
|
|
277
|
+
## Project Layout
|
|
278
|
+
|
|
279
|
+
```text
|
|
280
|
+
src/gradix/ Library implementation
|
|
281
|
+
tensor.py Tensor and automatic differentiation
|
|
282
|
+
nn/ Functions, modules, losses, and optimizer
|
|
283
|
+
demos/MnistModel.py Example fully connected and convolutional models
|
|
284
|
+
demos/demo.ipynb Small tensor and graph examples
|
|
285
|
+
demos/digraph_test.ipynb Attention-style and recurrent graph examples
|
|
286
|
+
demos/train.ipynb MNIST data preparation, training, and evaluation
|
|
287
|
+
tests/test_conv2d.py Conv2D comparison against PyTorch
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
## Development Checks
|
|
291
|
+
|
|
292
|
+
Install the project and run the Conv2D comparison from the repository root:
|
|
293
|
+
|
|
294
|
+
```bash
|
|
295
|
+
python -m pip install -e .
|
|
296
|
+
python -m pip install torch
|
|
297
|
+
PYTHONPATH=src python tests/test_conv2d.py
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
The test uses PyTorch only as a reference implementation. The library itself does not depend on PyTorch.
|
|
301
|
+
|
|
302
|
+
## Current Limitations
|
|
303
|
+
|
|
304
|
+
- Gradix is educational software and implements a small subset of tensor and neural-network functionality.
|
|
305
|
+
- CPU execution is the most suitable current path. CUDA support is experimental; Conv2D currently constructs some intermediate indices with NumPy and has not been validated on a real CUDA device.
|
|
306
|
+
- Models explicitly implement `parameters()`; nested layers are not automatically discovered.
|
|
307
|
+
- Dataset loading and notebook conveniences such as scikit-learn and tqdm are separate from the core package dependencies.
|
|
308
|
+
- The Graphviz Python package does not include the Graphviz system executable.
|
|
309
|
+
|
|
310
|
+
## License
|
|
311
|
+
|
|
312
|
+
See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
gradix/__init__.py,sha256=xXZ0omtpooFzkQyyqvHMnxaC1ZmDoyMTtsaUvawuoyI,548
|
|
2
|
+
gradix/init.py,sha256=GI2JmWUXLcvDArKQIddKrrp1hnKqJg4B4xW9eunwOjc,909
|
|
3
|
+
gradix/tensor.py,sha256=7vB61EMfRenYUvWW5arjAwUZjJnFgqyJAFvAewYAyTc,13056
|
|
4
|
+
gradix/utils.py,sha256=QfmnE8EYhIXH7WvjZur3EConSgjTmYTTGtehZ1taHyQ,1408
|
|
5
|
+
gradix/nn/__init__.py,sha256=lLxMRv99l_Y97XdYoJgSUObIDdOyhp1OQHT9SEcuVms,289
|
|
6
|
+
gradix/nn/functional.py,sha256=7lYDRynk-f8yse0IteUT05AchXXrCmeuJNIaZhAxVf0,3488
|
|
7
|
+
gradix/nn/loss/__init__.py,sha256=COTJEVrxp_TnKZNkP8G8mOPdpwUgkMaEcYNa7g4UPzs,72
|
|
8
|
+
gradix/nn/loss/crossentropyloss.py,sha256=7sBLVlYk9jGQqnoSL61XgXksCe5YC1xDk3y9ZwRraOU,937
|
|
9
|
+
gradix/nn/modules/__init__.py,sha256=I3btKSOvnVogXeBfAfj2pmu9IYkKi9bQ2iZd9nst7n0,166
|
|
10
|
+
gradix/nn/modules/conv2d.py,sha256=xdUITDRnEACCQnlhAnm6YrJYVKF8xOCLYWPu8aLny_k,2836
|
|
11
|
+
gradix/nn/modules/linear.py,sha256=96tk0ELPHkjYYhHQB7w_sP5HgFGXDrRl7oh8oSZaEME,1007
|
|
12
|
+
gradix/nn/modules/module.py,sha256=mk1pLooenoWMr3LeyqPIhWKp-IVqn1FRLH40EeuVgFU,365
|
|
13
|
+
gradix/nn/modules/upsample.py,sha256=GHkL_8FowMnlWfN41Zz9E7GVi9rJoPTiIQCluQnpF-w,402
|
|
14
|
+
gradix/nn/optim/SGD.py,sha256=ZI4c6ZKvyvR0dnZm5LDvGIOKd0vHcVEjLSeEsfBIEAU,407
|
|
15
|
+
gradix/nn/optim/__init__.py,sha256=s4ZojzOft3rmfAlZzq7M-zJS-Jyi88rAQnIA46QUMy4,39
|
|
16
|
+
gradix-0.1.0.dist-info/licenses/LICENSE,sha256=KTS90TmJspuDVTKJ2bWxw6khU19Dh2LYNb2kfqX8TQk,1076
|
|
17
|
+
gradix-0.1.0.dist-info/METADATA,sha256=BTmytZ4S0PkcpAooUSCTpAE26AfPmDB3CCMMi9jTGi4,12541
|
|
18
|
+
gradix-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
19
|
+
gradix-0.1.0.dist-info/top_level.txt,sha256=yUMOJsHaTXqqfKsHI-x15ERiCLfs3khC7Cl42NYx43k,7
|
|
20
|
+
gradix-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Gradix contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
gradix
|