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 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
+ ]
@@ -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,3 @@
1
+ from .crossentropyloss import cross_entropy
2
+
3
+ __all__ = ["cross_entropy"]
@@ -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,6 @@
1
+ from .conv2d import Conv2D
2
+ from .linear import Linear
3
+ from .module import Module
4
+ from .upsample import Upsample
5
+
6
+ __all__ = ["Module", "Linear", "Conv2D", "Upsample"]
@@ -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)
@@ -0,0 +1,3 @@
1
+ from .SGD import SGD
2
+
3
+ __all__ = ["SGD"]
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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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