 Welcome. In this video, we're going to do a deep dive on PyTorch tensors. In a PyTorch deep learning model, all of your data, inputs, outputs, learning weights, these are going to be expressed as tensors, multidimensional arrays that can contain floating point, integer, or boolean data. In particular, in this video, we're going to go over some of the ways to create PyTorch tensors, how to use tensors in mathematical and logical operations alone or with each other, methods for copying tensors, how to move to GPU for hardware acceleration, manipulating tensor shapes, and the PyTorch NumPy bridge. If you haven't already, I recommend going to the PyTorch examples repo and downloading the interactive notebook that goes with this video. Okay, so in the first cell here, we'll import PyTorch. We're also going to import Python's math module to use some constants it has. First thing we're going to go over is creating tensors. So here we have the very simplest way to create a tensor, the torch.empty call. The torch module has multiple factory methods that will let you create tensors with and without initial values and whatever data type you need. This is the most basic way to allocate a tensor, torch.empty. Here it's going to create a 3x4 tensor. And we can see that the object itself is of type torch.tensor. Now, when you run this cell, you may see random-looking values in the output. That's because torch.empty just allocates memory and does not write any values to it. So whatever happened to be memory at the time you allocated this tensor is what you're going to see here. One quick note about tensors and their dimensions and terminology. Sometimes when we have a one-dimensional tensor, we'll call it a vector because it's just an ordered tuple of dimensions or of coordinates. Likewise, a two-dimensional tensor is often referred to as a matrix, and anything larger we'll always call a tensor. Now, more often than not, you'll want to initialize your tensor with some value. Common cases are all zeros or all ones or random values. And the Torch module provides factory methods for all of these. So here, if we run the cell, you get the things that you might expect from the method name. You get a 2x3 tensor full of zeros, a two by three tensor full of ones, and then tensor full of random values between zero and one. Now, speaking of the random tensor, you might've spotted the call to torch.manual seed right before instantiating that tensor. So what's that about? Now, initializing tensors, such as your model learning weights with random values is very common. But often, you will want your results to be reproducible, especially if you're working in a research setting. So PyTorch gives you a tool for doing that, the manual seed call. Any time you call manual seed with a particular integer seed, you will reinitialize your pseudo random number generators and get the same results again when you call them. So here in the following cell as an example, we call manual seed, we call torch.rand, we get some values out, we call torch.rand again and get some values out. Then when we call manual seed again and do those two torch.rand calls, we'll see both times they yield the same values. So this is how you make sure that identical computations that depend on random numbers will provide identical results if you need that reproducibility. So often when you're performing operations on two or more tensors, they will need to be of the same shape. That is, having the same number of dimensions and the same number of cells in each dimension, or the same extent in each dimension. All the factory methods I've shown you on the Torch module so far have corresponding methods appended with underscore like. And when you pass in a tensor as an argument to empty like or zeros like or one of these other methods you will get out a tensor initialized as you specify but of the same shape as the tensor you passed in as an argument so here we run the cell and we can see that our initial tensor was two by two by three and even though we specified no shape for the other tensors they all will also come out two by two by three and initialized in the way you'd expect when we want to find out the shape of a tensor we can always query its shape property and this will give us back a list of the dimensions and their extents now the last way to create a tensor that we're going to cover is to specify its data directly from a PyTorch collection. So here, if you look at these examples, we have a nested array, and we have a tuple, and then we have a tuple that contains a tuple in a list. And when we call torch.tensor with any of these collections as an argument, we get back a new tensor that is initialized with the data we specified. So here you can see in all three cases, we've gotten back a tensor that is of the shape and containing the data that we expect. So torch.tensor creates a copy of the data. This is important to know. The underlying memory representation of a Python list is not the same as the underlying memory representation of a tensor, so we always copy that data when we're creating a new tensor and initializing it with data in this way. Now, I mentioned earlier that tensors can have a floating point or integer or boolean underlying data types. The simplest way to specify your data type is to do it at creation time. So here in this cell I'm creating an int 16 and a float 64 and you'll see A when I print it out is a set of ones represented as 16-bit integers and you can see none of the ones have that little decimal point after them which is Python's subtle signal that we're dealing with an int rather than a float. We also could see that because we overwrote the default data type, the default is a 32-bit floating point, when we print the tensor, PyTorch helpfully reports to us that this is the underlying data type of that tensor. Likewise, when we do a 64-bit float. The other way to change the data type of a tensor or to move it to a new tensor with your required data type is with the to method. So here I'm calling b.2 and saying I would rather have this data as 32-bit integers. And if you look closely at the values of b and c when they're printed out, the values of c are just the values of b truncated to make them integers. And if you look closely at the values of b and c when they're printed out, the values of c are just the values of b truncated to make them integers. So it's a float to int conversion there. The other thing you may have noticed here is that here I specified the dimensions of the tensor as a tuple. Canonically, PyTorch expects a tuple for the tensor's dimensions. But when the dimensions are the first argument of a method, it lets us cheat a little and just put in a series of integers. But here, to make the code a little more readable, I've separated out the tensor's shape as a tuple. The data types you can use are Boolean, five types of ints, and four types of float. Let's look at basic arithmetic first and how we can make tensors interact with scalars. Now, if we run this cell, see, let's look at the first line. Here we're going to create a tensor full of zeros, and we're going to add the integer 1 to it. So what does that mean to add an integer to a tensor? Well, here we're going to be doing this operation element-wise over every element of the tensor. So every zero in that tensor should have a one added to it. And if we look at our output, that is, in fact, what we see. Likewise, with multiplication, division, subtraction, exponentiation with integer or floating point powers. exponentiation with integer or floating point powers. I'll also note that because the binary operation between a tensor and a scalar puts out a tensor of the same shape you had originally, you can chain together these arithmetic operations intuitively. And you can see that in the line where we created the threes. Now doing these same arithmetic operations with two tensors behaves sort of intuitively like you'd expect. So we take our our twos, our little two by two tensor full of floating-point twos. We're going to use the exponentiation operator. Now we're going to specify the powers one, two, three, and four. And so here the mathematical operation is going to be done element-wise between corresponding elements of each tensor because they're of the same shape. And so if we run this cell you can see that in fact powers of 2 are in the first tensor. We've added two tensors ones and fours to get fives and if we multiply threes and fours we get twelves. tensors 1s and 4s to get 5s, and if we multiply 3s and 4s, we get 12s. A key thing here is that all the tensors that we've shown you in these examples of these tensor binary operations are of identical shape. So we can see when we run this cell that when we try to do an operation with two tensors of different shape, we get a runtime error, even though these two tensors have the exact same number of cells, there's no natural way to map between the two. So in the general case, your tensors will have to be of the same shape. There is one important and useful exception to that, and that is what we call broadcasting. Here is an example. So I've created a random tensor, two rows by four columns, and I'm multiplying it, here you can see, by a tensor with one row and four columns. We actually get out something like what we expect. So we see our random output in the first print statement and our second print statement shows all of that doubled. Now, how did we do this? How did we multiply two tensors of different shapes and get an intuitive result? So broadcasting is a way to perform an operation between tensors that have specific similarities in their shapes. So here, in the cell previous, the one row four column tensor was multiplied element wise by each of the two four column rows of the random tensor. So this is an important operation in deep learning. One common example is using batches of inputs. So your PyTorch machine learning model will, in the general case, not expect a single input for either training or inference, but will expect a batch of inputs. So here, applying an operation to each instance in the batch separately, but returning a tensor of the same shape is what you'd expect. So here we have a random tensor. We had two rows of random values. We multiplied by one row of twos, doing each row individually. And that's akin to the batch operation, that we're performing some operation on each segment of a tensor separately. There are rules for broadcasting. The first one is that no empty tensors. So every tensor must have at least one dimension. And then there are some rules for the relationship between the dimensions and extents of the two tensors that you want to perform an operation on. So when we compare the dimension sizes of the two tensors going from the last to the first, we have to have either each dimension must be equal or one of the dimensions must be of size 1 or the dimension doesn't exist in one of the tensors. Here are some examples that show the rules that I just described. It's probably easier to look at these than to try to reason them out. So we start with a tensor full of 1s. It's a three-dimensional tensor with four layers, three rows, and two columns. And we will multiply that by a random 3x2 tensor. If we look at the output of that, we can see that we've multiplied our random tensor by each of the four layers of our original tensor full of ones. And so what we say, that operation is broadcast over those layers, over that first dimension. Likewise, in the following line, here we multiply a times another random tensor to get c. This time we're doing a 3 by 1 tensor. And so what does that give us? This follows the rules, because in the last dimension, one of the dimensions is one, in the second dimension they match, and then the first dimension is absent in one of the tensors. The output there looks like this. So if we think of our random tensor that went into making C as a three element column vector, tensor that went into making C as a three element column vector. What you can see in the output here, when we multiply it by a bunch of ones, is that every three element column in our output tensor is the same. So we broadcast this operation over every three element column in our tensor. Likewise, in the final example, multiplying a random 1 by 2 tensor times our tensorful of 1s, that does something akin to the previous time, except now instead of every three element column having the operation performed on it, now every two element row has the operation performed on it. Now there's a PyTorch documentation note on this topic of broadcasting, and I urge you to read it if you are interested in more details. Now to give you an idea of some operations that will break the rules and not work, all these lines should give you a runtime error. So in the first case, trying to create B, we always compare the dimensions last to first, and B's last dimension is 3, or has an extent of 3. A's has an extent of 2. Those don't match. We can't broadcast the multiplication here. Likewise with C, the last two dimensions are 2 and 3 instead of 3 and 2. They're different. That won't work. And in the final example, when we try to create an empty tensor and broadcast an operation over one with dimensions, that doesn't work. We can't do it with an empty tensor. Now, PyTorch tensors have over 300 mathematical operations that you can perform on them. Here are a few examples from the major categories. In the first section, we just have some common functions that you might use for manipulating numbers, absolute value, ceiling, floor, and a clamp, which sets min and max values for your tensor. And all those will act on every element of the tensor. Likewise for trigonometric functions. So here I've created a tensor full of angles and I want to get the sine of those angles, and then get the inverse of that sine. And you can see from running the cell that we get back what we expect. We can do bitwise logical operations on either Boolean or integer tensors. Here I've got two integer tensors, and I'm performing a bitwise XOR on them. And we can see that it does exactly what you'd expect if you were doing like a bitwise XOR in C, for example. We can do comparisons of tensors as well. So we'll get a tensor where you specify some data, we'll get a tensor full of ones, we'll test their equality. We can see because the tensor D, its first value was one, but all the rest were different. We can see we got a true and three falses there, which was exactly what we'd expect. There are also a number of reduction operations that you can perform on a single tensor. So, for example, here we can take the maximum of a tensor. No matter how large the tensor, this is going to give us back a single value, or a tensor with a single value. If you want to extract that value from that one element output tensor, we use the.item call. And if you look at the output from these reduction ops, first we get back a tensor with our value in it, and then after the dot item call, we've actually extracted the value. You can also do means, standard deviations. There are convenience methods for performing arithmetic operations, including all of the elements of the tensor. So here with the dot prod call, we're taking the product of all numbers in the tensor. And we can also, you know, it's another example, get all the unique elements of a tensor. And all these behave more or less as you'd expect. Of course, linear algebra is at the heart of a lot of what we do in deep learning. So there are a lot of vector and matrix and linear algebra operations. So, for example, I'll create two vectors that correspond to x and y unit vectors. I'll create two matrices, one of which is just random and one of which is going to be three times the identity matrix. We can do some things with them. So torch.cross gets a cross product between the two vectors. So if we cross the y unit vector with the x unit vector, in that order, we should expect back the negative z unit vector, which is, in fact, what we got. We can do a matrix multiplication of our two matrices. So we have our random matrix. And then when we multiply it by three times the identity matrix, we should expect that we get back a matrix that is about three times the value of our input. And in fact, we see that. And you can do more advanced complex operations like singular value decomposition as well. And this is just a very small sampling of the 300-odd mathematical and logical operations associated with PyTorch tensors. I urge you to look at the documentation to understand the full inventory. Now, sometimes if you're doing a computation with two tensors, you'll see they're intermediate values of some kind. You may not need those intermediate values when you're done. It can be a nice optimization to be able to recycle that memory if you need a tensor of the same size and data type as the intermediate one you're going to throw away. So as an example of that, here again, I'm going to create a tensor full of angles. I'm going to get signs from them. And you can see when we run this cell and check the output that we have A, that's our angles, we have our signs of those angles. And then if we look back here, we can see A has not changed. So here we see torch.sign gave us back a new tensor and left the old one in place. But because we're acting on a single tensor here, and we don't need the input values, we could just put the outputs in that tensor itself. The underscore on a method like sign means that you're altering the tensor in place, that tensor you're putting in as an argument. So now if we do the exact same thing, B is a tensor containing the same angles that A did, and we do the same operation, we do a sign on it, we can see there's our initial angles, there's the output of that sign operation. But this time B has changed. We told it that we wanted to use B's memory for this, and it was of a compatible data type and size. And so B was altered in place. And if you wanna do this with binary arithmetic operations, there are functions for you that behave similarly to the binary PyTorch operators. So here we'll create two by two matrices, A and B. We can look at their values before. Now we'll call the in place addition method. And you can see here that now A has changed. Methods that cover a binary operation, the calling tensor will be the one that is changed in place. And so likewise here, when we do the same for B, if we square the random contents of B, but do it with the mull underscore, we'll get back exactly what we expect. Note that these in-place arithmetic functions are methods of the torch.tensor objects not attached to torch module like a lot of other functions. The calling tensor, as I said, is the one that gets changed in place. There's another option for placing the result of a computation in an existing already allocated tensor. Many of the methods and functions we've seen so far, including the creation methods for tensors, have an out argument that lets you specify a tensor to receive the output. If the out tensor is the same shape as the output and the correct data type that matches the output data type, this can happen without a new memory allocation. So if we run this cell, we'll create tensors A and B, which are two by two random matrices, and then C, which is a two by two matrix full of zeros. And we'll use the Python ID call to get the ID of that object. We'll print it out. We will do a matrix multiplication between A and B, and we'll specify C as that optional out argument. And then if we look at our next print of C, we'll see that it's changed. It's no longer zeros. So C was the same size as both A and B. It was the same data type, 32-bit floating point, PyTorch's default. And so when we do that multiplication, specify C to receive the output, we see that it does. We also assign the output as a return value to another label, D. And if we look, we'll see that C and D are actually the same object. This assertion didn't fire. So C and D are the same object. And we can also see via an assertion that C's ID did not change. We're dealing with the same object in memory. So I just wanted to give you a tangible example of how all that works. And it works for creation calls too. So when we call torch.rand with an optional out argument, again, as long as the shape and data type are what we want, we will get back a tensor in that same object. So we've seen how to create and manipulate tensors, but what about copying them? Now, tensors are like any object in Python, meaning that if you assign it to a variable, that variable is just a label for the object. You're not making a copy of the object. Create a tensor full of ones in this cell. We'll assign it to A. We'll say B equals A. And if we look, when we change a value of A and print B, the value within B has changed as well. So these are just two labels for the exact same object. What if you need a separate copy of the data? It may happen if you're building a complex model with multiple computation paths, and so you want to have separate copies of the input to pass to different portions of the model. So in this case, you would use the clone method. So we're going to do something very similar here. We're going to create a 2x2 matrix full of ones. We're going to say b is the same as a now, but now we're going to clone a instead of just doing the assignment. We can verify that these are in fact different objects in memory with the assertion, and we can verify that the contents are the same with the torch.eq call. And when we change A, again, we can verify that B has not changed when we print it out. So there is one important thing to be aware of using clone, which is that if your source tensor has autograd enabled, then so will the clone of that tensor. We're going to cover this more deeply in the video on autograd, but if you want a light version of the details, here it is. So as an example, let's say you have a complex model with multiple computation paths in its forward method, and the original tensor and its clone, or its copy, are going to contribute to the model's output. Then in order to enable model learning, you want autograd turned on for both tensors. If your source tensor has autograd enabled, which it generally will if it's a set of learning weights or it's derived from a computation involving the weights, then everything has autograd enabled already and you'll get the result you want. On the other hand, perhaps you're doing a computation where neither the original tensor nor its clone need to track gradients. In that case, as long as the source tensor has autograd turned off, you're good to go. There is, of course, a third case. So imagine you're performing some computation in your model's forward function where gradients are turned on for everything by default, but you want to pull out some values midstream to generate metrics. And you want those to be separate from the data that's being acted on. So in this case, you wouldn't want the cloned copy of your source tensor to track gradients. It degrades performance and doesn't actually do anything for you in this example use case. So for this, you can use the detach method on the source tensor. So if we run this, we'll see what we'll create our 2x2 tensor of random values. We will set requires grad equals true. So now every computation subsequent to A will have its history tracked, so we know where it came from and can compute backward derivatives. And that happens with clone. So here we clone b from a, and when we print b, we see that grad function equals clone backwards. That's telling us b is tracking its history. Now instead, if you look at the line where we create c, we say a.detach.clone, and then when we print c, we get the same data, except that we don't get the history attached. If we print a, we'll see that that detach call did not actually alter A at all. It basically just says do everything as if Autograd were turned off. One of the core advantages of PyTorch is hardware acceleration. If you have a CUDA compatible NVIDIA GPU and the driver is installed, you can radically accelerate your performance for both training and inference. Everything we've done so far has been on CPU. By default, when you create a tensor, it's instantiated in CPU memory. So how do we move to the faster hardware? First things first, we should check whether that faster hardware is available. And we do that with torch.cuda.isavailable. So if I run the cell, you should see it will tell us whether or not we have a GPU available on this device. Once you determine that one or more GPUs are available, you need to put the data someplace the GPU can see it. Your CPU works on data that lives in your machine's RAM. Your GPU also has dedicated memory attached to it. Whichever device you want to perform your computation on, you must move all the data needed for that operation to memory accessible by your target device. Now that we know that we have one or more GPUs available, we run. Now there are multiple ways to get your data onto your target device, but the easiest way is at creation time. You can see here, when we have a CUDA GPU available, we will create a tensor with an optional argument. It says device equals CUDA. When we create a tensor that we want to move to a GPU, we'll use the optional device argument on the factory method. All the factory methods I showed you for creating tensors will take this device argument. And here we're putting in the string CUDA to say, we'd like to move this tensor into memory accessible by the GPU. When you print the tensor, you'll see that it reports that the tensor is living on the GPU device. You can also query the number of GPUs. And if there is more than one, you can specify them by index with a colon after the CUDA string. So CUDA colon 0, CUDA colon 1, et cetera, would be the strings you put in as your device argument. As a general engineering practice, it's generally not considered good to specify things with magic constant strings all over your code. So a better practice is to, at the beginning of computation, choose which device you want on whether or not we have a GPU available or not, is either torch.device CUDA or torch.device CPU. Once we have the handle to that device, we can pass that in as the optional argument to creating a tensor, as shown in the last couple of lines there. That's creating a tensor. What if you have an existing tensor? It's living in memory for one device and you want to move it to the other device. How do you go about that? Well, in this cell, we'll demonstrate you can create a tensor in CPU, for example, and you want to move it to GPU. You can either put in the string CUDA or CUDA colon zero, CUDA colon one, or you could pass in a handle to a device that you have retrieved earlier. And you just pass it into the two method like so. This is the same two method that lets you change the data type of a tensor. If you wanted to change both the data type and the device, you would have to specify the names of the arguments. So dtype equals torch.float16, device equals myGPU. But that's how you move all your tensors, learning weights, everything from CPU memory to GPU. Sometimes you'll have a tensor and you'll need it to be in a different shape. So we're going to look at a couple of common cases and the tools PyTorch offers to help you handle that. So one case where you might need to change the number of dimensions is when you're passing a single instance of input to your model. PyTorch models expect batches of input rather than single instances. So for example, if we had an image classification model that took in a three-color image, 226 by 226 pixels, each input image would be represented as a 3 by 226 by 226 tensor. Your model is expecting a shape of n times 3 times 226 times 226, where n is the number of images in the batch, which might be, for example, 8 or 16 while you're doing training. But let's say you're doing training. But let's say you're doing inference one at a time. How do you make a batch of one? We can do that with the unsqueeze method. So we start with a random tensor meant to represent our input, the 3 by 226 by 226 image representation. And then we're going to call unsqueeze 0 and get that tensor and check its shape. And we'll see it's changed to 1 by 3 by 226 by 226. So we added a dimension at the beginning. That's what the 0 on unsqueeze says is we want this new dimension to be the first dimension, the one at index 0. That's unsqueezing. What do we mean then by squeezing? Here we're taking advantage of the fact that any dimension of extent 1 does not change the number of elements in the tensor. So for example here if we create C which is a 1 by 1 by 1 by 1 by 1 tensor, when we print it we see it only has one element and a lot of square brackets. So continuing the example above with our image classifier, let's say the model's output is a 20 element vector for each input. You then expect the output to have a shape of n by 20, where n is the number of instances that were in the input batch. So as many input instances as you put in, you want to have that many predictions coming out. That means for our single input batch, we'll get an output of shape 1 by 20. So what if you want to do non-batch computation with that output? So I think it's just expecting a 20 element vector. For that, we have squeeze. So what's happening here? Here we created a random 1 by 20 vector, again meant to stand in as our output tensor. We can check its shape and verify that it is 1 by 20, and then we can call squeeze zero on it. And so what that's saying is we want to take that dimension of extent one and squeeze it away. We call that, we look at the shape of b, following we can see it's just a 20 element tensor. Now this can only be done with dimensions of extent 1. So in the following stanza with the variables C and D, we create a random PyTorch tensor, and then we try to squeeze the first dimension of it. If you check the shape of the output of squeeze in that case, you'll find it's the same shape you started with. We didn't lose a dimension as we intended because there is no way to do that without destroying data in this case. So squeezing and unsqueezing will only work with dimensions of extent 1. Another place you might use unsqueeze is to help with broadcasting. If you recall earlier, we had some code where we were demonstrating broadcasting, where we took a 4x3x2 tensor, multiplied it by a 3x1 tensor, and the result, once we had the dimensions aligned, was that every three-element column in our original tensor had the same operation applied to it, the same multiplication. Now, what if, instead of 3 by 1, we just had a three-element column vector that we wanted to broadcast some operation over A. If we look at the next cell, we can see that if we just look at A and B as they're created right away, broadcasting can't happen there. The trailing dimensions don't line up. So what do we do? We use unsqueeze in this cell to create an extra dimension of extent one. And then when we multiply the random three-element vector against the larger tensor, we can see every three-element column in the tensor has the multiplication operation broadcast over it. So this can be a way to manipulate dimensions to get broadcasting to work for you without having to transpose dimensions on either of your tensors. Squeeze and unsqueeze methods also have in-place versions, like we saw earlier with the math methods. If I have one input instance and I want to make a batch of one, instead of calling unsqueeze, I can call unsqueeze with the underscore and do the operation in place. Now sometimes you'll want to change the shape of a tensor more radically while still preserving the number of elements in the tensor and their contents. In one case where this happens is again, taking the example of an image classifier. It's common in such models for the beginning of the computation to involve convolutional layers, and the end, the classification piece, to involve fully connected or linear layers. Now the convolutional layers, when they're working with images, will usually put out a three-dimensional tensor. It will have some horizontal and vertical extent meant to map the detection of features onto the image spatially. And then it will have a depth as well. And that will be the number of features that that convolution kernel has learned to recognize. The fully connected layers that follow, though, are expecting just a one-dimensional vector. So how do we translate between these two cases where we have an output vector that becomes an input vector, but it needs to change shape, but keep the same number of cells? Well, one way we can do that is with the reshape method. So here we'll create a 6 by 20 by 20 tensor that's a stand-in for our convolutional layer output, and we will reshape that into a one-dimensional tensor with 6 times 20 times 20 elements that stand in for the input into our fully connected layer. Now, when it can, Reshape will actually put out a view on the original tensor. So instead of creating a new tensor object with new memory allocation, it'll create a new tensor object addressing the same memory underlying the first tensor. So this is important by the way, if you use reshape and it feeds you back a view of the original tensor, changes in the source tensor will be reflected in the new tensor unless you clone it. And there are conditions beyond the scope of this introduction where reshape has to return tensor with the data copied. For more information, there's a documentation on the topic, which I urge you to read. The last topic I want to cover on this introduction to tensors is the data portability between NumPy tensors and PyTorch tensors. Now, in the section above, we mentioned briefly that PyTorch's broadcasting semantics are just like NumPy's. But the connection between the two libraries goes even deeper than that. If you have existing machine learning or scientific code with data stored in NumPy ND arrays, you may wish to express that same data as PyTorch tensors, whether to take advantage of PyTorch's GPU acceleration or its efficient abstractions for building deep learning models. It's easy to switch between NumPy ND arrays and PyTorch tensors. So in the first cell here, I'll be importing NumPy, and we'll create a NumPy array, a two by three matrix full of ones. Now, to express that as a PyTorch tensor, we call torch.fromNumpy with the NumPy array as an argument. We get back a tensor and we print it out. We will see that it's the same shape. It contains the same data and even goes so far as preserving the 64-bit floating point data type, NumPy's default. The conversion is just as easy the other way. So here we'll create a random PyTorch tensor and we'll call it.numpy on it and we'll get back a NumPy in D array. Now it's important to know these converted objects are using the same underlying memory as their source objects, meaning that changes to one are reflected in the other. So when I run the final cell, what you'll see is change the value of one element of a NumPy array, and we see that reflected in the PyTorch tensor that we made from it. Likewise, when I change a value in the PyTorch tensor we made, it's reflected in the NumPy tensor we created from that. So again, if you have code already that's manipulating your data in NumPy, moving over to PyTorch is a breeze. That is our first deep dive on tensors. The topics we covered today and the documentation at pytorch.org should be all you need to get going on the videos later in this series, but as well on your own work within PyTorch. Thank you for listening.
Introduction to PyTorch Tensors
Tensors are the fundamental data abstraction within PyTorch. This video covers everything you'll need to get started using PyTorch tensors, including: How to create and copy tensors, performing math & logic operations on tensors, doing tensor computations on a GPU, reshaping tensors, and NumPy compatibility of PyTorch tensors.

Download the Tensors notebook here: https://pytorch-tutorial-assets.s3.amazonaws.com/youtube-series/Video+2+-+Tensors.ipynb