From 4a71db0e383353103189882d05d23fc2d6c3b43a Mon Sep 17 00:00:00 2001 From: Nicolas Date: Tue, 19 May 2026 20:27:42 +0200 Subject: [PATCH] concat and sigmoid function added, moved relu and sigmoid to _nn.py, renamed min and max to minimum and maximum like in numpy and updated functions to work with tensors --- src/copapy/__init__.py | 13 +++--- src/copapy/_math.py | 70 ++++++++++++++++--------------- src/copapy/_nn.py | 34 +++++++++++++++ src/copapy/_tensors.py | 86 ++++++++++++++++++++++++++++++++++++-- tests/test_math.py | 8 ++-- tests/test_tensor_basic.py | 21 ++++++++++ tests/test_vector.py | 13 ++++++ 7 files changed, 198 insertions(+), 47 deletions(-) create mode 100644 src/copapy/_nn.py diff --git a/src/copapy/__init__.py b/src/copapy/__init__.py index 680e6a4..808dd4e 100644 --- a/src/copapy/__init__.py +++ b/src/copapy/__init__.py @@ -37,8 +37,9 @@ from ._target import Target, jit from ._basic_types import NumLike, value, generic_sdb, iif from ._vectors import vector, distance, scalar_projection, angle_between, rotate_vector, vector_projection from ._quaternion import quaternion -from ._tensors import tensor, zeros, ones, arange, eye, identity, diagonal -from ._math import sqrt, abs, sign, sin, cos, tan, asin, acos, atan, atan2, log, exp, pow, get_42, clamp, min, max, relu +from ._tensors import tensor, zeros, ones, arange, eye, identity, diagonal, concat +from ._math import sqrt, abs, sign, sin, cos, tan, asin, acos, atan, atan2, log, exp, pow, get_42, clamp, minimum, maximum +from ._nn import relu, sigmoid from ._autograd import grad from ._tensors import tensor as matrix from ._version import __version__ # Run "pip install -e ." to generate _version.py @@ -74,16 +75,18 @@ __all__ = [ "pow", "get_42", "clamp", - "min", - "max", + "minimum", + "maximum", "relu", "distance", "scalar_projection", "angle_between", "rotate_vector", -"vector_projection", + "vector_projection", "quaternion", "grad", "eye", + "concat", + "sigmoid", "jit" ] diff --git a/src/copapy/_math.py b/src/copapy/_math.py index fcd14c9..4ad6204 100644 --- a/src/copapy/_math.py +++ b/src/copapy/_math.py @@ -393,12 +393,20 @@ def clamp(x: U | value[U] | vector[U], min_value: U | value[U], max_value: U | @overload -def min(x: value[U], y: U | value[U]) -> value[U]: ... +def minimum(x: U, y: U) -> U: ... @overload -def min(x: U | value[U], y: value[U]) -> value[U]: ... +def minimum(x: value[U], y: U | value[U]) -> value[U]: ... @overload -def min(x: U, y: U) -> U: ... -def min(x: U | value[U], y: U | value[U]) -> Any: +def minimum(x: U | value[U], y: value[U]) -> value[U]: ... +@overload +def minimum(x: vector[U], y: U | value[U] | vector[U]) -> vector[U]: ... +@overload +def minimum(x: U | value[U] | vector[U], y: vector[U]) -> vector[U]: ... +@overload +def minimum(x: tensor[U], y: U | value[U] | tensor[U]) -> tensor[U]: ... +@overload +def minimum(x: U | value[U] | tensor[U], y: tensor[U]) -> tensor[U]: ... +def minimum(x: U | value[U] | vector[U] | tensor[U], y: U | value[U] | vector[U] | tensor[U]) -> Any: """Minimum function to get the smaller of two values. Arguments: @@ -408,22 +416,30 @@ def min(x: U | value[U], y: U | value[U]) -> Any: Returns: Minimum of x and y """ - if isinstance(x, value): + if isinstance(x, tensor) or isinstance(y, tensor): + return _map2_tensor(x, y, minimum) + if isinstance(x, vector) or isinstance(y, vector): + return _map2_vector(x, y, minimum) + if isinstance(x, value) or isinstance(y, value): return add_op('min', [x, y]) - if isinstance(x, tensor): - return _map2_tensor(x, y, min) - if isinstance(x, vector): - return _map2_vector(x, y, min) return x if x < y else y @overload -def max(x: value[U], y: U | value[U]) -> value[U]: ... +def maximum(x: U, y: U) -> U: ... @overload -def max(x: U | value[U], y: value[U]) -> value[U]: ... +def maximum(x: value[U], y: U | value[U]) -> value[U]: ... @overload -def max(x: U, y: U) -> U: ... -def max(x: U | value[U], y: U | value[U]) -> Any: +def maximum(x: U | value[U], y: value[U]) -> value[U]: ... +@overload +def maximum(x: vector[U], y: U | value[U] | vector[U]) -> vector[U]: ... +@overload +def maximum(x: U | value[U] | vector[U], y: vector[U]) -> vector[U]: ... +@overload +def maximum(x: tensor[U], y: U | value[U] | tensor[U]) -> tensor[U]: ... +@overload +def maximum(x: U | value[U] | tensor[U], y: tensor[U]) -> tensor[U]: ... +def maximum(x: U | value[U] | vector[U] | tensor[U], y: U | value[U] | vector[U] | tensor[U]) -> Any: """Maximum function to get the larger of two values. Arguments: @@ -433,12 +449,12 @@ def max(x: U | value[U], y: U | value[U]) -> Any: Returns: Maximum of x and y """ - if isinstance(x, value): + if isinstance(x, tensor) or isinstance(y, tensor): + return _map2_tensor(x, y, maximum) + if isinstance(x, vector) or isinstance(y, vector): + return _map2_vector(x, y, maximum) + if isinstance(x, value) or isinstance(y, value): return add_op('max', [x, y]) - if isinstance(x, tensor): - return _map2_tensor(x, y, max) - if isinstance(x, vector): - return _map2_vector(x, y, max) return x if x > y else y @@ -470,20 +486,6 @@ def lerp(v1: U | value[U] | vector[U], v2: U | value[U] | vector[U], t: unifloa return v1 * (1 - t) + v2 * t -@overload -def relu(x: U) -> U: ... -@overload -def relu(x: value[U]) -> value[U]: ... -@overload -def relu(x: vector[U]) -> vector[U]: ... -@overload -def relu(x: tensor[U]) -> tensor[U]: ... -def relu(x: U | value[U] | vector[U] | tensor[U]) -> Any: - """Returns x for x > 0 and otherwise 0.""" - ret = x * (x > 0) - return ret - - def _map2_vector(self: VecNumLike, other: VecNumLike, func: Callable[[Any, Any], value[U] | U]) -> vector[U]: """Applies a function to each element of the vector and a second vector or scalar.""" if isinstance(self, vector) and isinstance(other, vector): @@ -499,9 +501,9 @@ def _map2_vector(self: VecNumLike, other: VecNumLike, func: Callable[[Any, Any], def _map2_tensor(self: TensorNumLike, other: TensorNumLike, func: Callable[[Any, Any], value[U] | U]) -> tensor[U]: """Applies a function to each element of the vector and a second vector or scalar.""" if isinstance(self, vector): - self = tensor(self.values, (len(self.values),)) + self = tensor(self) if isinstance(other, vector): - other = tensor(other.values, (len(other.values),)) + other = tensor(other) if isinstance(self, tensor) and isinstance(other, tensor): assert self.shape == other.shape, "Tensors must have the same shape" return tensor([func(x, y) for x, y in zip(self.values, other.values)], self.shape) diff --git a/src/copapy/_nn.py b/src/copapy/_nn.py new file mode 100644 index 0000000..7ff1c96 --- /dev/null +++ b/src/copapy/_nn.py @@ -0,0 +1,34 @@ +from . import vector +from . import tensor +from . import value +from typing import TypeVar, Any, overload +import copapy as cp + +U = TypeVar("U", int, float) + + +@overload +def relu(x: U) -> U: ... +@overload +def relu(x: value[U]) -> value[U]: ... +@overload +def relu(x: vector[U]) -> vector[U]: ... +@overload +def relu(x: tensor[U]) -> tensor[U]: ... +def relu(x: U | value[U] | vector[U] | tensor[U]) -> Any: + """Returns x for x > 0 and otherwise 0.""" + ret = x * (x > 0) + return ret + + +@overload +def sigmoid(x: U) -> float: ... +@overload +def sigmoid(x: value[U]) -> value[float]: ... +@overload +def sigmoid(x: vector[U]) -> vector[float]: ... +@overload +def sigmoid(x: tensor[U]) -> tensor[float]: ... +def sigmoid(x: U | value[U] | vector[U] | tensor[U]) -> Any: + """Sigmoid function to map any value to the range (0, 1).""" + return 1 / (1 + cp.exp(-x)) diff --git a/src/copapy/_tensors.py b/src/copapy/_tensors.py index 740d302..522b7e4 100644 --- a/src/copapy/_tensors.py +++ b/src/copapy/_tensors.py @@ -32,7 +32,7 @@ class tensor(ArrayType[TNum]): self.shape: tuple[int, ...] = tuple(shape) assert (isinstance(values, Sequence) and any(isinstance(v, (value, int, float)) for v in values)), \ - "Values must be a sequence of values if shape is provided" + "Values must be a sequence of scalars if shape is provided" self.values: tuple[TNum | value[TNum], ...] = tuple(v for v in values if not isinstance(v, Sequence)) self.ndim: int = len(shape) elif isinstance(values, (int, float)): @@ -856,6 +856,84 @@ def ones(shape: Sequence[int] | int) -> tensor[int]: return tensor([1] * size, tuple(shape)) +@overload +def concat(tensors: Sequence[vector[U]]) -> vector[U]: ... +@overload +def concat(tensors: Sequence[tensor[U]], axis: int = 0) -> tensor[U]: ... +def concat(tensors: Sequence[tensor[U] | vector[U]], axis: int = 0) -> tensor[U] | vector[U]: + """Concatenate tensors or vectors along a specified axis. + + Arguments: + tensors: Tensors or vectors to concatenate. Must have the same shape except for the specified axis. + axis: Axis along which to concatenate (default 0). + + Returns: + A new tensor or vector resulting from concatenation. + """ + assert tensors, "At least one tensor must be provided" + + # Check if all inputs are vectors + all_vectors = all(isinstance(item, vector) for item in tensors) + + # Convert vectors to tensors for uniform processing + tensor_list: list[tensor[U]] = [] + for item in tensors: + if isinstance(item, vector): + tensor_list.append(tensor(item.values, item.shape)) + else: + tensor_list.append(item) + + first_shape = tensor_list[0].shape + ndim = len(first_shape) + + if axis < 0: + axis += ndim + + assert 0 <= axis < ndim + + # Shape checks + for t in tensor_list: + assert len(t.shape) == ndim + for i in range(ndim): + if i != axis: + assert t.shape[i] == first_shape[i] + + # Output shape + new_shape = list(first_shape) + new_shape[axis] = sum(t.shape[axis] for t in tensor_list) + + # Compute block sizes + inner_block: int = 1 + for s in first_shape[axis + 1:]: + inner_block *= s + + outer_block: int = 1 + for s in first_shape[:axis]: + outer_block *= s + + new_values: list[value[U] | U] = [] + + for outer in range(outer_block): + for t in tensor_list: + axis_size = t.shape[axis] + start = outer * axis_size * inner_block + end = start + axis_size * inner_block + new_values.extend(t.values[start:end]) + + result_tensor = tensor(new_values, tuple(new_shape)) + + # If all inputs were vectors and result is 1D, return as vector + if all_vectors and result_tensor.ndim == 1: + return vector(result_tensor.values) + + return result_tensor + + +def flatten(t: tensor[U]) -> tensor[U]: + """Flatten a tensor to a 1D tensor.""" + return t.flatten() + + def arange(start: int | float, stop: int | float | None = None, step: int | float = 1) -> tensor[int] | tensor[float]: """Create a tensor with evenly spaced values. @@ -927,10 +1005,10 @@ def identity(size: int) -> tensor[int]: @overload -def diagonal(vec: 'tensor[int] | vector[int]') -> tensor[int]: ... +def diagonal(vec: tensor[int] | vector[int]) -> tensor[int]: ... @overload -def diagonal(vec: 'tensor[float] | vector[float]') -> tensor[float]: ... -def diagonal(vec: 'tensor[Any] | vector[Any]') -> 'tensor[Any]': +def diagonal(vec: tensor[float] | vector[float]) -> tensor[float]: ... +def diagonal(vec: tensor[Any] | vector[Any]) -> tensor[Any]: """Create a diagonal tensor from a 1D tensor. Arguments: diff --git a/tests/test_math.py b/tests/test_math.py index a6361ce..e5d83a5 100644 --- a/tests/test_math.py +++ b/tests/test_math.py @@ -23,8 +23,8 @@ def test_fine(): cp.abs(-c_f), cp.sign(c_i), cp.sign(-c_f), - cp.min(c_i, 5), - cp.max(c_f, 5)) + cp.minimum(c_i, 5), + cp.maximum(c_f, 5)) re2_test = (a_f ** 2, a_i ** -1, @@ -39,8 +39,8 @@ def test_fine(): cp.abs(-a_f), cp.sign(a_i), cp.sign(-a_f), - cp.min(a_i, 5), - cp.max(a_f, 5)) + cp.minimum(a_i, 5), + cp.maximum(a_f, 5)) ret_refe = (a_f ** 2, a_i ** -1, diff --git a/tests/test_tensor_basic.py b/tests/test_tensor_basic.py index 9ddf85e..cdde7ef 100644 --- a/tests/test_tensor_basic.py +++ b/tests/test_tensor_basic.py @@ -3,6 +3,7 @@ import copapy as cp + def test_tensor_basic(): # Test 1: Create a scalar tensor print("Test 1: Scalar tensor") @@ -225,6 +226,7 @@ def test_tensor_basic(): assert t.ndim == 3 print() + def test_tensor_slicing(): print("Test Numpy-style slicing") t = cp.tensor([[10, 20, 30], [40, 50, 60], [70, 80, 90]]) @@ -251,6 +253,25 @@ def test_tensor_slicing(): assert slice4[2] == 90 print() + +def test_tensor_concat(): + print("Test tensor concatenation") + t1 = cp.tensor([[1, 2], [3, 4]]) + t2 = cp.tensor([[5, 6], [7, 8]]) + t3 = cp.tensor([[9, 10], [11, 12]]) + + concat_0 = cp.concat([t1, t2, t3], axis=0) + print(f"Concatenate along axis 0: shape={concat_0.shape}") + assert concat_0.shape == (6, 2) + assert concat_0[4, 1] == 10 + + concat_1 = cp.concat([t1, t2, t3], axis=1) + print(f"Concatenate along axis 1: shape={concat_1.shape}") + assert concat_1.shape == (2, 6) + assert concat_1[1, 4] == 11 + print() + + if __name__ == "__main__": test_tensor_basic() print("All tests completed!") diff --git a/tests/test_vector.py b/tests/test_vector.py index b7ce95c..849da43 100644 --- a/tests/test_vector.py +++ b/tests/test_vector.py @@ -114,6 +114,19 @@ def test_sort_vector(): assert ref == result +def test_vector_concat(): + print("Test vector concatenation") + v1 = cp.vector([1, 2, 3]) + v2 = cp.vector([4, 5]) + v3 = cp.vector([6]) + + concat_vec = cp.concat([v1, v2, v3]) + print(f"Concatenate vectors: shape={concat_vec.shape}") + assert concat_vec.shape == (6,) + assert concat_vec[4] == 5 + print() + + if __name__ == "__main__": #test_vectors_init() #test_compiled_vectors()