Tensors
1. What is Tensor
A tensor is a mathematical object that generalizes scalars, vectors, and matrices to higher dimensions. It’s essentially a container for data that can be indexed in multiple dimensions.
Tensor vs ndarray
Tensor is similar to the fundamental object in Numpy called ndarray
ndarray is defined as an n-dimensional homogeneous array of fixed-size items
Advantages of Tensor
Tensor operations are performed significantly faster using GPUs
Tensors can be stored and manipulated at scale using distributed processing on multi GPUs and GPUs and across multiple servers
Tensors keep track of the graph of computations that created them
Tensors are much more than a special sort of multi-dimensional arrays
Interactions with each other such that transforming the tensors as a whole means that each tensor follows a particular transformation rule
2. Creat Tensors
2.1 From Python objects
import torch
print(torch.__version__)
zero_tensor = torch.tensor([2])
oneD_tensor = torch.tensor((1,2,3))
twoD_tensor = torch.tensor([[1,2,3], [4,5,6]]) # can be list or tuple
treD_tensor = torch.tensor([[[1,2,3], [4,5,6]], [[7,8,9], [10,11,12]]])
# tensor.size()
print(f'zero_tensor = {zero_tensor} and its tensor size = {zero_tensor.size()}\n')
print(f'oneD_tensor = {oneD_tensor} and its tensor size = {oneD_tensor.size()}\n')
print(f'twoD_tensor = {twoD_tensor} and its tensor size = {twoD_tensor.size()}\n')
print(f'treD_tensor = {treD_tensor} and its tensor size = {treD_tensor.size()}')
2.2 From Numpy objects
# pytorch
import numpy as np
twoD_tensor = torch.tensor(np.array([1,3,5,7,9]))
print(f'twoD_tensor = {twoD_tensor} and its tensor size = {twoD_tensor.size()}\n')
twoD_tensor = torch.tensor(np.arange(1, 10, 2))
print(f'twoD_tensor = {twoD_tensor} and its tensor size = {twoD_tensor.size()}\n')
np_array = np.array([1, 2, 3, 4, 5])
tensor_x = torch.from_numpy(np_array)
print(f'tensor_x = {tensor_x} and its tensor size = {tensor_x.size()}\n')
# tensorflow
import tensorflow as tf
# from a Python list
tensor_tf1 = tf.constant([1, 2, 3, 4])
print(tensor_tf1)
# from a NumPy array
tensor_tf2 = tf.convert_to_tensor(np.array([[1, 2], [3, 4]]), dtype=tf.float32)
print(tensor_tf2)
2.3 From functions to create tensors
torch.zeros()
andtorch.zeros_like()
,tf.zeros()
andtf.zeros_like()
torch.ones()
andtorch.ones_like()
,tf.ones()
andtf.ones_like()
torch.empty()
andtorch.rand_like()
torch.full()
andtorch.full_like()
empty_tensor = torch.empty(3,3)
print(f'empty_tensor = {empty_tensor} and its tensor size = {empty_tensor.size()}\n')
zeros_tensor = torch.zeros(3,3)
print(f'zeros_tensor = {zeros_tensor} and its tensor size = {zeros_tensor.size()}\n')
ones_tensor = torch.ones(3,3)
print(f'ones_tensor = {ones_tensor} and its tensor size = {ones_tensor.size()}\n')
full_tensor = torch.full((3,3), 0.12)
print(f'full_tensor = {full_tensor} and its tensor size = {full_tensor.size()}\n')
print('----------------------------------------------------\n')
a_tensor = torch.rand(2, 4)
print(f'a_tensor = {a_tensor} and its tensor size = {a_tensor.size()}\n')
a_zeros = torch.zeros_like(a_tensor)
print(f'a_zeros = {a_zeros} and its tensor size = {a_zeros.size()}\n')
a_ones = torch.ones_like(a_tensor)
print(f'a_ones = {a_ones} and its tensor size = {a_ones.size()}\n')
a_rand = torch.rand_like(a_tensor)
print(f'a_rand = {a_rand} and its tensor size = {a_rand.size()}\n')
a_full = torch.full_like(a_rand, 7) # here this matrix should be filled with `7`
print(f'a_full = {a_full} and its tensor size = {a_full.size()}\n')
# generating tensor using tensorflow
import tensorflow as tf
zeros_tensor = tf.zeros([3,3])
print(f'zeros_tensor = {zeros_tensor} and its tensor shape = {zeros_tensor.shape}\n')
ones_tensor = tf.ones([3,3])
print(f'ones_tensor = {ones_tensor} and its tensor shape = {ones_tensor.shape}\n')
# torch.eye(n)
tensor_eye = torch.eye(3)
print(f'tensor_eyee = \n{tensor_eye}\n and its tensor size = {tensor_eye.size()}\n')
# torch.linspace(start, end, steps)
# torch.logspace(start, end, steps)
lin = torch.linspace(0, 23, steps=5)
print(f'lin = {lin} and its tensor size = {lin.size()}\n')
log = torch.logspace(-3, 7, steps=4)
print(f'log = {log} and its tensor size = {log.size()}\n')
2.4 Random tensors
torch.manual_seed(123456) # to keep data reproduciable
# generate values from a uniform distribution on the interval [0, 1)
a_tensor = torch.rand(3,3)
print(f'a_tensor = {a_tensor} and its tensor size = {a_tensor.size()}\n')
# standard normal distribution
a_tensor = torch.randn(3,3)
print(f'a_tensor = {a_tensor} and its tensor size = {a_tensor.size()}\n')
# `torch.randint(a, b, (m, n))` generate a `m*n` integers from `a` to `b`
a_tensor = torch.randint(1, 10, (4,5))
print(f'a_tensor = {a_tensor} and its tensor size = {a_tensor.size()}\n')
# `torch.randperm(n)` to create a tensor with a random permutation of integers
a_tensor = torch.randperm(10)
print(f'a_tensor = {a_tensor} and its tensor size = {a_tensor.size()}\n')
3. Tensor’s properties
3.1 Tensor.shape
a_tensor = torch.rand(3,3,3)
print(f'a_tensor = {a_tensor} and its tensor shape = {a_tensor.shape}\n')
a_tensor = torch.rand(3, 3)
print(f'a_tensor = {a_tensor} and its tensor shape = {a_tensor.shape}\n')
a_tensor = torch.rand(3)
print(f'a_tensor = {a_tensor} and its tensor shape = {a_tensor.shape}\n')
a_tensor = torch.rand(1)
print(f'a_tensor = {a_tensor} and its tensor shape = {a_tensor.shape}')
3.2 Tensor.ndim
a_tensor = torch.rand(3,3,3)
print(f'a_tensor = {a_tensor} and its tensor ndim = {a_tensor.ndim}\n')
a_tensor = torch.rand(3, 3)
print(f'a_tensor = {a_tensor} and its tensor ndim = {a_tensor.ndim}\n')
a_tensor = torch.rand(3)
print(f'a_tensor = {a_tensor} and its tensor ndim = {a_tensor.ndim}\n')
a_tensor = torch.rand(1)
print(f'a_tensor = {a_tensor} and its tensor ndim = {a_tensor.ndim}')
3.3 Tensor.dtype
a_tensor_int = torch.tensor([1,2,3,4,5])
print(f'a_tensor_int = {a_tensor_int} and its tensor dtype = {a_tensor_int.dtype}\n')
b_tensor_flt = torch.tensor([1,2,3,4,5], dtype=torch.float) # can use `float64`
print(f'b_tensor_flt = {b_tensor_flt} and its tensor dtype = {b_tensor_flt.dtype}\n')
# torch.int8, torch.int16, torch.int32, torch.int64, torch.float16, torch.float32, torch.float64
print('-----------------------------------------------------------------\n')
# data type conversion
print('------- data type conversion (two methods) -------\n')
a_tensor_flt1 = a_tensor_int.float()
print(f'a_tensor_flt1 = {a_tensor_flt1} and its tensor dtype = {a_tensor_flt1.dtype}\n')
a_tensor_flt1 = a_tensor_int.to(torch.float64) # or .to(dtype=torch.double)
print(f'a_tensor_flt1 = {a_tensor_flt1} and its tensor dtype = {a_tensor_flt1.dtype}\n')
4. Tensor operations
4.1 Indexing
torch.manual_seed(123456)
a_tensor = torch.rand(5,5)
print(f'a_tensor = {a_tensor} and its tensor shape = {a_tensor.shape}\n')
a_tensor_row = a_tensor[2]
print(f"A ROW of a_tensor = {a_tensor_row}\n")
a_tensor_col = a_tensor[:, 2]
print(f"A COL of a_tensor = {a_tensor_col}\n")
a_tensor_cubic = a_tensor[1:4, 1:4]
print(f"A inner cubic of a_tensor = \n{a_tensor_cubic}\n")
# use indexing to extract data that meets some criteria
print("Extra data > 0.5 = ", a_tensor_cubic > 0.5)
print("Extra data > 0.5 = ", a_tensor_cubic[a_tensor_cubic > 0.5])
4.2 Combining Tensors
a_tensor = torch.tensor([[0,1,2],[3,4,5]])
print(f'a_tensor = {a_tensor}\n')
b_tensor = torch.tensor([[10,11,12],[13,14,15]])
print(f'b_tensor = {b_tensor}\n')
# using `torch.stack` to add new dimension and stacks -- creates a new dimension
stack_tensor_dim0 = torch.stack((a_tensor, b_tensor), dim=0) # stack by rows
print(f'stack_tensor_dim0 = \n{stack_tensor_dim0} and its tensor shape = {stack_tensor_dim0.shape}\n')
stack_tensor_dim1 = torch.stack((a_tensor, b_tensor), dim=1) # stack by columns
print(f'stack_tensor_dim1 = \n{stack_tensor_dim1} and its tensor shape = {stack_tensor_dim1.shape}\n')
# using `torch.cat` to concatenates along existing dim -- extends an existing dimension.
cat_tensor_dim0 = torch.cat((a_tensor, b_tensor), dim=0) # cat by rows
print(f'cat_tensor_dim0 = \n{cat_tensor_dim0} and its tensor shape = {cat_tensor_dim0.shape}\n')
cat_tensor_dim1 = torch.cat((a_tensor, b_tensor), dim=1) # cat by columns
print(f'cat_tensor_dim1 = \n{cat_tensor_dim1} and its tensor shape = {cat_tensor_dim1.shape}\n')
4.3 Split Tensors
Function |
Purpose |
Input Parameters |
Output |
Changes Shape? |
Splits Tensor? |
---|---|---|---|---|---|
|
split into fixed number of chunks |
chunks (int), dim |
Tuple of tensors |
No |
Yes |
|
split into chunks of specified sizes |
split_size_or_sections, dim |
Tuple of tensors |
No |
Yes |
|
reshape tensor |
New shape |
Single tensor |
Yes |
No |
|
remove a dimension, split into slices |
dim |
Tuple of tensors |
Yes (removes dim) |
Yes |
torch.manual_seed(101)
a_tensor = torch.rand(5,5)
print(f"a_tensor = {a_tensor}\n")
print('--------- torch.unbind(tensor) ---------')
tensors = torch.unbind(a_tensor, dim=1)
print(f"first_tensor = {tensors}")
tensors = torch.unbind(a_tensor, dim=0)
print(f"first_tensor = {tensors}")
print('--------- torch.split(tensor) ---------')
tensors = torch.split(a_tensor, 3, dim=0) # split by rows
print(f"tensors = {tensors}\n")
tensors = torch.split(a_tensor, 3, dim=1) # split by cols
print(f"tensors = {tensors}\n")
print('--------- torch.chunk(tensor) ---------')
chunks = torch.chunk(a_tensor, 3, dim=0)
print(f"chunks = {chunks}\n")
print(f"chunks[0] = {chunks[0]}")
print(f"chunks[1] = {chunks[1]}")
print(f"chunks[2] = {chunks[2]}\n")
chunks = torch.chunk(a_tensor, 3, dim=1)
print(f"chunks = {chunks}\n")
# `torch.view` provides an easy way to reshape a tensor
a_tensor = torch.randn(9)
print(f"a_tensor = \n{a_tensor}\n")
b_tensor = a_tensor.view(3,3)
print(f"b_tensor = \n{b_tensor}\n")
# `torch.flatten` method can be used to collapse the dimensions of a given tensor starting with a particular dimension
a_tensor = torch.randn(2,3,4)
print(f"a_tensor = \n{a_tensor}\n")
b_tensor = torch.flatten(a_tensor, start_dim=0)
print(f"b_tensor = \n{b_tensor}\n")
c_tensor = torch.flatten(a_tensor, start_dim=1)
print(f"c_tensor = \n{c_tensor}\n")
d_tensor = torch.flatten(a_tensor, start_dim=2)
print(f"d_tensor = \n{d_tensor}\n")
print(f"tensor shapes = b:{b_tensor.shape}")
print(f"tensor shapes = c:{c_tensor.shape}")
print(f"tensor shapes = d:{d_tensor.shape}")
4.4 Build-in Math Functions
pointwise operations
reduction functions
comparison functions
linear algebra operations
spectral and other math computations
4.4.1 Pointwise operations
To perform an operation on each point in tensor individually and return a new tensor
basic math functions, like
add()
,sub()
,mul()
,div()
,neg()
, andtrue_divid()
functions for truncation, like
ceil()
,clamp()
,floor()
, etc.logical functions
trigonometry functions, like
sin()
,cos()
, etc.
a = torch.tensor((11,12,13,14,15))
b = torch.tensor([1,2,3,4,5])
print(f"tensor a = {a}")
print(f"tensor b = {b}\n")
print(f"tensor a+b = {a+b} and {a.add(b)}")
print(f"tensor a+b = {a-b} and {a.sub(b)}")
print(f"tensor a+b = {a*b} and {a.mul(b)}")
print(f"tensor a+b = {a/b} and {a.div(b)}\n")
print(f"Sin of tensor a = {torch.sin(a)}")
print(f"Cos of tensor b = {torch.cos(b)}")
a = torch.tensor((11,12,13))
b = torch.tensor([1,2,3])
# `dot()` allows you to compute the dot product of tensors
print(f"tensor dot = {torch.dot(a, b)}")
# `cross()` allows to compute cross product of two tensors
print(f"tensor cross = {torch.cross(a, b)}")
4.4.2 Reduction functions
Reduce numbers down to a single number or a smaller set of numbers
results in reducing the dimensionality or rank of a tensor
include statistical functions such as mean, median, mode, etc.
a = torch.tensor((11,12,13,14,15), dtype=torch.float)
b = torch.tensor([1,2,3,4,5])
print(f"Mean of tensor a = {torch.mean(a)}")
print(f"Median of tensor b = {torch.median(b)}")
print(f"Mode of tensor b = {torch.mode(b)}")
print(f"Standard deviation of tensor a = {torch.std(a)}")
4.4.3 Comparison functions
compare all values within a tensor or compare values of two different tensors
functions to find minimum or maximum value, sort tensor values, test tensor status or conditions, etc.
torch.manual_seed(123456)
a_tensor = torch.rand(2,3)
b_tensor = torch.rand(2,3)
print(f"a_tensor = \n{a_tensor}\n")
print(f"b_tensor = \n{b_tensor}\n")
ge_torch = torch.ge(a_tensor, b_tensor).long()
print(f"ge_torch = \n{ge_torch}\n")
lt_torch = torch.lt(a_tensor, b_tensor).long()
print(f"lt_torch = \n{lt_torch}\n")
# torch.le(), torch.eq(), torch.ne()
# `any` and `all` methods enable to check whether a given condition is true in any or all cases
a_tensor = torch.randn(3,3)
print(f"Tensor A = {a_tensor}\n")
print(torch.any(a > 0.0).long())
print(torch.any(a > 1.0))
print(torch.all(a > 0.0))
print(torch.all(a > 1.0))
4.4.4 Linear algebra operations
enable matrix operations and are essential for DL computations
functions for matrix computations and tensor computations
PyTorch has a module called
torch.linalg
than contains a set of build-in functions that are based on BLAS and LAPACK standardized libraries
# compute dot product (scalar) of two 1D tensors
a_tensor = torch.tensor([1, 2, 3, 4], dtype=torch.int32)
b_tensor = torch.tensor(np.arange(1,9,2), dtype=torch.int32)
ab_tensor = torch.matmul(a_tensor, b_tensor)
print(f"Torch.matmul for two 1D scalarss = {ab_tensor}")
# compute matrix-matrix product (2D tensor) of two 2D tensors
a_tensor = torch.tensor([[1, 2, 3], [4, 5, 6]])
b_tensor = torch.tensor([[1, 2], [3, 4], [5, 6]])
ab_tensor = torch.matmul(a_tensor, b_tensor)
print(f"Torch.matmul for two 2D matrices = {ab_tensor}\n")
# compute a matrix product of multiple 2D tensors
a_tensor = torch.rand(2, 3)
b_tensor = torch.rand(3, 4)
c_tensor = torch.rand(4, 5)
d_tensor = torch.rand(5, 6)
abcd_tensor = torch.matmul(torch.matmul(a_tensor, b_tensor), torch.matmul(c_tensor, d_tensor))
print(f"Torch.matmul for four 2D matrices = \n{abcd_tensor}")
abcd_tensor = torch.linalg.multi_dot((a_tensor, b_tensor, c_tensor, d_tensor))
print(f"Torch.linalg.multi_dot for four 2D matrices = \n{abcd_tensor}")
# compute eigenvalues and eigenvectors
a_tensor = torch.rand(4,4)
print(f"Tensor A = {a_tensor}\n")
eigenvalues, eigenvectors = torch.linalg.eigh(a_tensor)
print(f"Eigenvalues = {eigenvalues}\n")
print(f"Eigenvectors = {eigenvectors}")
# `addbmm` (bmm = batch matrix-matrix product) to perform computation p*m+q*[a1*b1+a2*b2+...]
# where p and q are scalars, and m, a1, b1, a2, and b2 are tensors
# Note that `addbmm` takes parameters p and q with default values equal to one
# and that tensors such as a1 and a2 are provided by stacking them along 1st dimension
a = torch.ones(2, 2, 3)
b = torch.ones(2, 3, 2)
m = torch.ones(2,2)
print(f"shapes of a, b, and m tensors are {a.shape}, {b.shape}, and {m.shape}\n")
print(torch.addbmm(2, m, 3, a, b), '\n')
print(torch.addbmm(1, m, 1, a, b))
# `baddbmm` to perform p1 * m + q * [a1 * b1], p2 * m + q * [a2 * b2], ...
# where p and q are scalars, and m, p1, a1, b1, p2, a2, and b2 are tensors
# Note that `baddbmm` takes parameters p and q with default values equal to one
# and that tensors such as p1, a1, and a2 are provided by stacking them along 1st dimension
a = torch.ones(2, 2, 3)
b = torch.ones(2, 3, 2)
m = torch.ones(2, 2, 2)
print(f"shapes of a, b, and m tensors are {a.shape}, {b.shape}, and {m.shape}\n")
print(torch.baddbmm(1, m, 1, a, b), '\n')
print(torch.baddbmm(2, m, 1, a, b), '\n')
print(torch.baddbmm(1, m, 2, a, b))
# `bmm` to perform batch-wise matrix multiplication for tensor
a = torch.ones(2, 2, 3)
b = torch.ones(2, 3, 2)
print(f"shapes of a, b tensors are {a.shape},and {b.shape}\n")
print(torch.bmm(a, b))
# `addmm` is a non-batch version of addbmm that allows to perform computation p * m + q * a * b
# where p and q are scalars, and m, a, and b are tensors
# Note that `addmm` takes parameters p and q with default values equal to one
a = torch.ones(2, 3)
b = torch.ones(3, 2)
m = torch.ones(2, 2)
print(f"shapes of a, b, and m tensors are {a.shape}, {b.shape}, and {m.shape}\n")
print(torch.addmm(m, a, b), '\n')
print(torch.addmm(2, m, 3, a, b), '\n')
print(torch.addmm(1, m, 1, a, b))
# `addmv` (matrix-vector) allows to perform computation p * m + q * a * b
# where p and q are scalars, m and a are matrices, and b is a vector
# Note that `addmv` takes parameters p and q with default values equal to one
a = torch.ones(2, 3)
b = torch.ones(3)
m = torch.ones(2)
print(f"shapes of a, b, and m tensors are {a.shape}, {b.shape}, and {m.shape}\n")
print(torch.addmv(m, a, b), '\n')
print(torch.addmv(2, m, 3, a, b), '\n')
print(torch.addmv(1, m, 1, a, b))
addr
allows to perform an outer product of two vectors and add it to a given matrix
outer product of two vectors in linear algebra is a matrix
e.g., if you have a vector V with m elements (1 dimension) and another vector U with n elements (1 dimension), then outer product of V and U will be a matrix with m × n shape
V= [v1, v2, v3…, vm]
U = [u1, u2, ……un]
V ⊕ U = A = [ v1u1, v1u2, …. , v1um,’\n’ v2u1, v2u2, …. , v2um,’\n’ ….. vnu1, vnu2, …. , vnum]
In PyTorch, function expects 1st argument as matrix to which we need to add resultant outer product, followed by vectors for which outer product needs to be computed
below we create two vectors (a and b) with three elements each, and perform an outer product to create a 3 × 3 matrix, which is then added to another matrix (m)
a = torch.tensor([1.0, 2.0, 3.0])
b = a
m = torch.ones(3,3)
print(f"shapes of a, b, and m tensors are {a.shape}, {b.shape}, and {m.shape}\n")
print(torch.addr(m, a, b), '\n')
m = torch.zeros(3,3)
print(torch.addr(m, a, b), '\n')
4.5 Activation functions
a_tensor = torch.linspace(-1.0, 1.0, steps=9)
print(f"a_tensor = {a_tensor}")
print(f"using sigmoid function = {torch.sigmoid(a_tensor)}]")
print(f"using tanh function = {torch.tanh(a_tensor)}]")
print(f"using log1p function = {torch.log1p(a_tensor)}]")
print(f"using erf function = {torch.erf(a_tensor)}]")
print(f"using erfinv function = {torch.erfinv(a_tensor)}]")
print(f"using sigmoid function = {torch.sigmoid(a_tensor)}]")
print(f"using tanh function = {torch.tanh(a_tensor)}]")
print(f"using log1p function = {torch.log1p(a_tensor)}]")
print(f"using erf function = {torch.erf(a_tensor)}]")
print(f"using erfinv function = {torch.erfinv(a_tensor)}]")
5. Tensors in DL frameworks
5.1 Tensor.device (CPU vs CUDA)
a_tensor = torch.rand(3,4) # 'cpu by default'
print(f'a_tensor = {a_tensor} and its tensor device = {a_tensor.device}\n')
a_tensor = torch.rand(3,4, device='cpu')
print(f'a_tensor = {a_tensor} and its tensor device = {a_tensor.device}\n')
a_tensor = torch.rand(3,4, device='cuda') # cuda for gpu, there is no error if I have GPU
print(f'a_tensor = {a_tensor} and its tensor device = {a_tensor.device}\n')
print(f"PyTorcch version: {torch.__version__}\n")
if torch.cuda.is_available():
device='cuda'
else:
device='cpu'
# device = 'cuda' if torch.cuda.is_available() else 'cpu'
print(f"Device: {device}\n")
a_tensor = torch.rand((2,4),device=device)
print(f'a_tensor = {a_tensor} and its tensor device = {a_tensor.device}\n')
b_tensor = torch.rand((2,4),device='cpu')
print(f'b_tensor = {b_tensor} and its tensor device = {b_tensor.device}\n')
# print(a_tensor + b_tensor) # not compatible as is on CPU and one is on GPU
Moving Tensors between CPUs and GPUs
Transfer data from CPU to GPU
After training, output Tensors are produced in GPU
Output data requires preprocessing
Some preprocessing libraries don’t support Tensors and expect a Numpy array
Numpy supports only data in GPU, and we need to move data from GPU to CPU
# Data from CPU to GPU -- 3 methods
a_tensor = torch.rand((2,4),device='cpu')
print(f'a_tensor = {a_tensor} and its tensor device = {a_tensor.device}\n')
# 1. `Tensor.cuda()`
b_tensor = a_tensor.cuda()
print(f'b_tensor = {b_tensor} and its tensor device = {b_tensor.device}\n')
# 2. `Tensor.to("cuda")`
b_tensor = a_tensor.to("cuda")
print(f'b_tensor = {b_tensor} and its tensor device = {b_tensor.device}\n')
# 3. `Tensor.to("cuda:0")`
b_tensor = a_tensor.to("cuda:0")
print(f'b_tensor = {b_tensor} and its tensor device = {b_tensor.device}\n')
# Data from GPU to CPU -- 2 methods
a_tensor = torch.rand((4), requires_grad=True, device='cuda')
print(f'a_tensor = {a_tensor} and its tensor device = {a_tensor.device}\n')
# 1. `Tensor.cpu()` with `requires_grad=False`
b_tensor = a_tensor.cpu()
print(f'b_tensor = {b_tensor} and its tensor device = {b_tensor.device}\n')
# 2. `Tensor.detach().cpu() with `requires_grad=True`
b_tensor = a_tensor.detach().cpu()
print(f'b_tensor = {b_tensor} and its tensor device = {b_tensor.device}\n')
# tensor to numpy arrays
a_tensor = torch.rand((2,2), requires_grad=True, device='cuda')
print(f'a_tensor = {a_tensor} and its tensor device = {a_tensor.device}\n')
a_array = a_tensor.detach().cpu().numpy()
print(f'a_array = {a_array} and its array device = {a_array.device}\n')
b_tensor = torch.from_numpy(a_array)
print(f'b_tensor = {b_tensor} and its tensor device = {b_tensor.device}\n')