Repository navigation
Expand file tree
/
Copy path1_Pytorch_basic_introduction.py
More file actions
3153 lines (2756 loc) · 138 KB
/
Copy path1_Pytorch_basic_introduction.py
File metadata and controls
3153 lines (2756 loc) · 138 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#%% In the name of God the most compassionate the most merciful
# Pytorch basics : Introduction of tensors
import torch
import numpy as np
# bunch of utility functions for our tutorial ahead
def print_header(title, width=40, fillchar='─', newline=True):
if newline:
print()
print(f"{f' {title} '.center(width, fillchar)}")
def show_tensor(name,t,newline=True):
print_header(f"{name}", newline=newline)
print(f"shape={tuple(t.shape)!s:<10}")
print(t)
#%% Introduction
# In this chapter we are going to learn about pytorch and see how we can build
# different kinds of models but before we actually do any of that, we first
# need to get familiar with the library we'll be using, which is obviously pytorch!
#
# before we continue, I just need to add that I initially wrote this for myself,
# as a memo/reminder/review kind of thing so I could refresh my memory from time
# to time. thats why you see lots of sidenotes, and repeated explanations.
# later on I tried to fix that, and started improving this to some extend, however
# I never got the time to actually do it justice yet.
# I might have done a bit too much on the sidenotes, but if you know, you can always skip
# I was planning on formating this as a nice notebook, where each sidenote could be
# presented as a tooltip, or a sidenote or a footnote depending on the type but never
# got the chance. I hope I can fix this in a near future.
#
# so, having said all of this, lets get back to our discussion, what were we talking about?
# yup pytorch, so what exactly is pytorch?
#
# Pytorch is an open-source deep learning framework that we use for tensor computation
# and deep learning. It provides us with a few important facilities that makes
# building and training neural networks for us especially easy and efficient.
# to name a few of thse facilities we can name, very fast and GPU accelerated tensor operations,
# automatic differentiation (Autograd*), and a rich ecosystem of tools for building
# and training neural networks.
#
# sidenote:
# During this tutorial, you may encouter many concepts that you may not be familar with
# dont worry, we will explain all of them later on, we may not immediately expand on them
# (many times we actually do using sidenotes) but we will cover them eventually so we are good!
#
#
# sidenote 1 - on Automatic diffrentiation - Autograd:
# There are a few different ways to calculate derivatives in a neural network.
# we can do it manually, which obviously doesn't scale to large networks,
# There's also the symbolic differentiation, where we generate an algebraic
# expression for the derivative and then there's the Automatic Differentiation (Autograd),
# which is what pytorch uses, which is specifically a tape-based approach.
# The basic idea is that whenever we perform an operation on tensors,
# say, an addition or a multiplication, Autograd records that operation,
# in order, on a kind of virtual tape (think a stack, but more involved, more in a second)
# Then, when we do backpropagation, it works backward through that tape,
# starting from the end and applying the chain rule step by step.
# therefor we don't have to manually calculate or keep track of all those
# derivatives. Autograd figures out how each parameter contributed to
# the loss and computes the corresponding gradients for us.
# A good way to build an intuition for this is to think of an old cassette
# tape, as we perform operations, we record them one after another.
# Later, we rewind the tape and go backward through everything to figure
# out how each value was produced.
# note when I said, you can think of a tape as like a stack but more involved,
# I meant that although the way the data is stored and retrieved looks like a
# stack, Pytorch doesnt simply use a stack to implement this. in practice
# Pytorch creates a graph (a computation graph) so it can keep track of how
# the tensors and operations are connected to each other! each operation is
# represented as a node and these relationships are what Pytorch keeps track of
# to be able to calculate the deriviative atuomatically hence why we call it
# tape based autograd!
#
# mini-sidenote:
# we actually build an autograd system ourselves, to learn more see the transformer directory, and the refresher section.
#
#
# Sidenote 2 - Pytorch or torch? whats the difference?:
# Torch is the underlying library that pytorch wraps around, its what performs the actual
# heavy lifting when it comes to tensors operations and it is implemented primarily in C/C++
# (along with CUDA support for NVIDIA GPUs). Pytorch is an ecosystem after all and
# is made of several libraries beside torch.
#
# basically torch gives us everything we need to create and manipulate tensors,
# perform fast numerical computations, automatically calculate gradients, and
# eventually build and train neural networks.
#
# you might have also noted the Py prefix in Pytorch, and have guessed, it
# signifies its a Python package, and you are spot on. but, what you may not
# know is that, originally torch was a popular deep learning framework written
# in the Lua programming language. Pytorch was later introduced as its spiritual
# successor, to bring the same ideas to Python.
# so today, whenever you see/hear someone say torch or PyTorch, he/she almost
# always means the Python package built on top of the torch library. the lua
# version is long abandoned and not used anymore.
# we have more wrappers for other languages. for example for C++, its called libtorch.
#
# Pytorch revolves heavily around tensors, if you have previous experience with Numpy,
# you can think of a tensor as something like a Numpy array(ndarray), but with a few
# important distinctions.
#
# many things in Pytorch feel very familiar if you've worked with Numpy before.
# this is because Pytorch intentionally follows many of NumPy's conventions,
# simply because numpy is a hugely popular library and has been used
# extensively in scintific communities. this makes it easy to pick up torch when you
# already know Numpy! If you haven't used NumPy before, don't worry, we'll explain
# everything we need as we go.
#
# also needless to say, since PyTorch is just another Python package, it works naturally
# alongside other python packages and libraries such as NumPy, SciPy, OpenCV,
# Matplotlib, PIL, etc and its one of many features that makes it loved by so many people!
#
#
# Throughout this chapter, we'll gradually build our understanding of PyTorch,
# We'll start by learning what tensors are and how to create them. Then we'll
# learn how to inspect them, manipulate them, perform mathematical operations,
# and understand how PyTorch keeps track of gradients behind the scenes.
#
# Don't worry if some of these terms sound/feel unfamiliar right now. By the end of
# this chapter they'll hopefully feel much more natural.
#
# So, let's start with the most fundamental building block in PyTorch, the Tensors.
# Quick Summary
# PyTorch is a Python framework for tensor computation and deep
# learning. It provides fast tensor operations, automatic differentiation
# (Autograd), GPU acceleration, and a rich ecosystem of tools for building and
# training neural networks.
#
# Torch is the core library implemented in C/C++ that Pytorch is built upon.
#
# Before we dive into the details, it'd help a lot to have a general/holistic view
# of the PyTorch ecosystem. so lets first get a basic understanding about
# the Pytorch echosystem first and then continue our discussion on tensors.
#
# We can think of PyTorch as a toolbox where each module has a specific responsibility.
# We don't need to learn all of them at once, but it is useful to know they exist
# and what problems they solve.
#
# At the center of everything is torch itself. This is where tensors live.
# It provides tensor creation, mathematical operations, indexing, linear algebra,
# random number generation, GPU support, and automatic differentiation.
# Basically the very foundation we need to build a deep learning model, hence
# why we start with this!
# this is the part that will feel the most familiar if you already know NumPy.
#
# On top of torch sits torch.nn. This module provides the building blocks
# used to construct neural networks, such as Linear layers, Convolution layers,
# Recurrent layers, activation functions like ReLU, normalization layers,
# pooling layers, and loss functions. Instead of implementing these ourselves,
# we simply combine them to build a model.
#
# Once we have a model, we need a way to optimize it. That is the job of torch.optim.
# It implements optimization algorithms such as SGD, Adadelta, Adam, RMSProp,
# AdamW and many others. These optimizers know how to update the
# parameters of our neural network based on the gradients computed
# during backpropagation.
#
# Another important module is torch.utils. It contains utilities that
# make training much easier.
# The most commonly used ones are Dataset and DataLoader, which help
# us load, preprocess, shuffle and batch our data efficiently.
# Nearly every PyTorch project uses a DataLoader at some point.
#
# PyTorch also provides torch.autograd. This is the automatic differentiation engine
# we briefly talked about earlier.
# Instead of manually computing derivatives, autograd records the
# operations we perform on tensors and automatically computes gradients
# for us during backpropagation. This is one of the key technologies
# that makes deep learning practical.
#
# As models become larger, training efficiently becomes more important.
# Modules such as torch.cuda and torch.amp help here.
# torch.cuda gives us access to NVIDIA GPUs, while torch.amp
# (Automatic Mixed Precision) allows training with lower precision
# floating-point numbers, making training faster while using less memory.
#
# PyTorch also contains many specialized modules for particular tasks.
# For example, torch.linalg provides a rich collection of linear algebra
# routines, torch.fft implements Fast Fourier Transforms,
# torch.distributions contains probability distributions useful in
# probabilistic models and reinforcement learning, and torch.special
# provides many advanced mathematical functions.
#
# Beyond the core library, PyTorch has an ecosystem of domain-specific
# libraries built on top of it.
# For example `torchvision` is used for computer vision (datasets, image transforms,
# pretrained vision models) which we use heavily in this book. we also have `torchaudio`
# for audio and speech processing likewise there is `torchtext` that is used for
# natural language processing and text datasets. another example is `torchrec`
# which is used for recommendation systems. these are just a few examples, there
# are more!
# These libraries build upon the same tensor and autograd system,
# so once you understand the PyTorch fundamentals, learning these
# becomes much easier.
#
# So, if we summarize PyTorch, we can think of it in layers:
# tensors -> represented by torch
# automatic gradients -> handled by autograd
# neural network layers -> provided by torch.nn
# optimization -> handled by torch.optim
# data loading -> handled by torch.utils.data
# hardware acceleration -> torch.cuda and torch.amp
# specialized domains -> torchvision, torchaudio, torchtext, etc.
#
# fortunately, we dont need to master the entire ecosystem before
# becoming productive. Most PyTorch projects spend the majority of
# their time using only a handful of these modules. As we progress
# through this tutorial, we'll build up this toolbox gradually, learning
# each module only when we actually need it. By the end, you'll not
# only know how to use PyTorch, but also where to look whenever you
# encounter a new problem.
#%% section intro
# we are going to cover a few sections and by the end of this chapter
# we should have a basic understanding of how to use torch and tensors
# and will be ready to learn more advanced concepts about deeplearning
# training and implementation.
#%% Section 1: What is a Tensor and How to create one
# The first concept we are going to learn about is Tensors,
# Tensor, is the most fundamental concept we will be dealing
# the most first.
#
# 1.1 So what is a tensor?
# Simply put, a tensor is a general name given to arrays.
# We can think of a tensor as a multi-dimensional array,
# that generalizes the concepts of scalers(0D, i.e. numbers),
# vectors(1D arrays) ,matrices(2D arrays) and higher dimensional arrays.
#
# So, whenever we talk about tensors, remember that
# it's simply a more flexible and encompassing term for arrays.
#
# Why do we care so much about them?
# Aside from the terminology nuiacense- whether we call these
# data structures arrays or tensors- the real reason they
# are important to us, is that, we use them to to represent
# multi-dimensional data, like images, audio, text, etc in
# deeplearning (and machinelearning in general).
# They are implemented very efficiently, and try to utilize
# every help they can get, like using hardware acceleration (i.e. GPUs on our systems),
# to efficiently do parrallel computation on large datasets
# and complex models which are typical for deeplearning.
# This alone is very significant as it can result in orders
# of magnitude in performance difference compared to when
# we use generic CPU based solutions to do the same thing!
# With these optimized implementations of tensor operations,
# its very easy to build and train complex neural networks.
# This is the very reason, and the most important one why we use them.
#
# Different numerical computing libraries refer to multi-dimensioanl
# arrays, by different names, e.g. PyTorch and Tensorflow among others
# refer to them as Tensors, while Numpy, Cupy and MxNet call it ndarray
# (n-dimensional array), Others like Jax call it Array.
# Most deep learning libraries use Tensor though.
# sidenote:
# Unlike other libraries we mentioned here, Numpy doesn't offer GPU
# acceleration. CuPy (a cuda based gpu accelerated library like Numpy)
# instead does, CuPy's interface is highly compatible with NumPy and
# SciPy; in most cases we can use it as a drop-in replacement.
# Note however, NumPy and CuPy are not deeplearning/machinelearning framework
# neither NumPy nor CuPy support automatic differentiation.
# They are just pure numerical computation libraries, where as others such as
# PyTorch, Tensorflow, Jax or MXNet are specificlly designed for deeplearning and
# offer automatic diffrentiation as a core feature among other tensor
# computation capabilities.
#
#
# sidenote:
# Pytorch is the most commonly used deeplearining framework in the world,
# especially among researchers a great number of new papers in the field
# publish their implementations using Pytorch. As a result when we learn
# and use Pytorch we can run/experiment with sota works right away!
#
# sidenote:
# a tensor can be 0 dimensional like a scaler(an ordinary number),
# 1 dimensional like a vector, 2 dimensional like a matrix or
# as we'll soon see, more dimensional which is usually
# what we refer to as simply a [multi-dimensional] tensor!
# we dont have a specific name for higher dimensional arrays,
# so instead we use the general term 'tensor' for them.
#
# sidenote:(exessive?obvious?)
# In torch/coding nomenclecture however, since tensor is the basic
# building block on which the whole process is based on -- its the
# class that implements basically everything we use to train networks--
# any dimensional object thats inherited from Tensor is called tensor!
# so regardless of its dimensions we call them simply tensors!
#
# Here we are going to have a very crude introduction to some of
# these operations in pytorch and familiarize ourselves with some
# of them.
#
# note that usually they may not make much sense right away,
# but later on when we actually try to do something meaningful
# with them, like implementing certain algorithms/operations/modules,
# it will all click and they will make much more sense.
# To get there we need to have a basic idea. this is essential
# for that basic understanding!
#
# 1.2 Creating new tensors
# To create a new tensor we can use several appraoches.
# we can use the Tensor() class, or use factory functions such as `.zeros()`,
# `.ones()`,`.rand()` and `.empty()` to say a few.
#
# lets create a few tensors with pre defined shapes and default values
# size (5), (2, 2), (3, 5, 6) using the functions we just learned about!
#
t = torch.Tensor(size=(1,)) # Using uninitialized scaler
t1_zeros = torch.zeros(size=(5,)) # using all zeros
t1_ones = torch.ones(size=(2, 2)) # using all ones
t1_rand = torch.rand(size=(2, 2, 2)) # Using uniform distribution [0,1]
t1_randn = torch.randn(size=(2, 2, 2)) # Using normal distribution (mean=0, std=1)
t1_empty = torch.empty(size=(2, 2, 2)) # Using uninitialized memory
# TODO : put all tensors on a new line so they look nicer when printed,
print('Tensor((1,))')
print(t)
print('\nZeros((5,)):')
print(t1_zeros)
print('\nOnes((2,2)):')
print(t1_ones)
print('\nRand((2,2,2)):')
print(t1_rand)
print('\nEmpty((2,2,2)):')
print(t1_empty)
# We use `torch.zeros()` when we want a tensor to have zero values everywhere.
# likewise if we want to have a tensor with 1 as values we use `torch.ones()`
# They come handy when we want to do operations such as add, multiplication,
# etc or do masking which we will learn about shortly.
#
# we use `torch.rand()` to create a tensor with random values from a
# uniform distribution. like numpy, torch offers other variants, such
# as as `torch.randn` for normal distribution sampling, `torch.randint()`
# to generate random integer numbers and much more.
#
# We may also want to create a tensor quickly, without initializing it
# with anything really, in this case we use `torch.empty()` which creates
# an empty tensor in the sense that its not initialized so it has whatever
# values that happens to be on the memory where it points to.
# its not empty(as in having all values equals to 0)
# its empty in the sense, its not pre-initialized. this is especially
# useful for cases where we want to fill a tensor with some calculations,
# and thus it doesnt make sense to initialize it with a value to only be
# replaced later, which would result in unnecessary computation overhead
# and slower speed! its equivalent to doing `torch.Tensor()`
#
# What if we want our tensors to have specific data!
# like we have our own data and need to create a tensor for it
# how do we do that?
# There are several ways to do this, but the simplest one is
# to simply send our data using a list or a numpy array!
#
# Here we are creating a tensor from a raw python list of numbers (1, 2, 3, 4)!
list_data = [1,2,3,4]
tensor_from_list = torch.tensor(list_data)
print(f'\nTensor from list: {tensor_from_list}')
# Using a numpy array
array_np = np.random.rand(4)
tensor_from_np_copy = torch.tensor(array_np)
print(f'\nNumpy Array:\n{array_np}')
print(f'\nTorch Tensor-Copied:\n{tensor_from_np_copy}')
# sidenote,
# note we are using `torch.tensor()` (lowercase function, and not the class Tensor())
# `torch.Tensor` is the base tensor class.
# All tensors are instances of `torch.Tensor`.
# When we call `torch.Tensor()`, we get an empty tensor without any data.
# On the other hand, `torch.tensor()` is a factory function that constructs
# a tensor with the given data and it infers the data type automatically.
# For example consider the following examples:
print(f'\ntorch.Tensor(10):\n{torch.Tensor(10)}') # Returns an uninitialized FloatTensor with 10 values.
print(f'\ntorch.tensor(10):\n{torch.tensor(10)}') # Returns a LongTensor containing a single value (10)
# So `torch.Tensor` is the main class constructor when we call it with a shape/size,
# it returns an uninitialized `FloatTensor` (equivalent to `torch.empty`).
# But `torch.tensor` is a factory function that expects data as its argument
# and infers the type.
# sidenote:
# One nice utility Pytorch provides, is `set_printoptions()`
# that allows us to specify different properties related to
# how a tensor gets displayed when printed. if we look at
# previous examples, we see that some examples have different
# decimal points(precision) than others when printed.
# using this routine, we can specify the precision, among
# other things and have a nicer, easier to follow result.
# note this is a great help for debugging which we later
# come to talk about in the upcomming chapters.
# lets set the precision to 4 decimals
random_v = torch.randn(3,)
print("\nDefault Behavior")
print(f'Random Tensor: {random_v}')
print(f'Random Tensor: {random_v.numpy()}')
torch.set_printoptions(precision=2, sci_mode=False)
np.set_printoptions(precision=2, suppress=True)
print("\nUsing PrintOption's Precision=2")
print(f'Random Tensor: {random_v}')
print(f'Random Numpy Array: {random_v.numpy()}')
# What if we need to reset it back to the defaults?
# pretty easy! we can use the `'default'` profile and
# just go back to default!
torch.set_printoptions(profile='default')
np.set_printoptions()
print("\nUsing PrintOption's Default profile")
print(f'Random Tensor: {random_v}')
print(f'Random Numpy Array: {random_v.numpy()}')
# We will talk about this in more detail in the OprintOptions
# chapter.
# 1.3 Shared Memory vs. Copying (Bridging PyTorch and NumPy)
# We can directly create a new tensor from a numpy array!
# unlike the previous way, this uses the same underlying
# numpy array so no copying happens here!
shared_tensor = torch.from_numpy(array_np)
print(f'\nShared tensor from numpy: {shared_tensor}\n')
# this is the way to go for large numpy arrays to prevent massive
# overhead due to copying time!
# Intrestingly we can access the underlying numpy array
# from any tensor, using its `numpy()` method!
# this is how sharing the underlying memory looks:
print('\nBefore modification:')
print(f' NumPy Array: {array_np}')
print(f' Shared tensor: {shared_tensor}\n')
# if we look closely we can see that both the numpy and
# torch array point to the same memory location when we
# use `torch.from_numpy()` to create the tensor,
# if we change the value in the torch tensor, the values
# in the numpy array will change also and vice versa.
# However this will not be the case when we use torch.tensor()
# which creates a copy from the given data!
# print('Shared tensor shares the underlying data with array_np')
shared_tensor[0] = 999
print('\nAfter modifying shared_tensor(PyTorch side):')
print(f' NumPy Array: {array_np}')
print(f' Shared tensor: {shared_tensor}\n')
# now lets change a value in array_npy
array_np[0] = -999
print('\nAfter modifying array_np(NumPy side):')
print(f' NumPy Array: {array_np}')
print(f' Shared Tensor: {shared_tensor}\n')
# we can see this by checking their storage address as well
numpy_address = array_np.ctypes.data
tensor_address = shared_tensor.data_ptr()
print('\nNumpy Data address vs Shared Tensor data address')
print(f' NumPy data address: {numpy_address}')
print(f' Tensor data address: {tensor_address}')
print(f' Shared memory ? {numpy_address == tensor_address}\n')
# However as we mentioned earlier, torch.tensor() does not!
print('\nNumpy Data vs torch.tensor() data')
array_np[0] = 5
tensor_cpy_address = tensor_from_np_copy.data_ptr()
print('\nAfter setting array_np[0] = 5')
print(f' NumPy Array: {array_np}')
print(f' Torch.tensor(): {tensor_from_np_copy}\n')
print('\nNumpy Data address vs torch.tensor() data address')
print(f' NumPy data address: {numpy_address}')
print(f' Tensor data address: {tensor_cpy_address}')
print(f' Shared memory ? {numpy_address == tensor_cpy_address}\n')
# As we said earlier we can convert a tensor back to numpy
# and this operation also shares the underlying memory
print('\nSharing Memory between torch tensor and numpy with .numpy()')
tensor_to_convert = torch.ones((3,3))
np_from_tensor = tensor_to_convert.numpy() # shares memory!
# modify one to see the effect on both
tensor_to_convert *= 2
print(f' Torch Tensor: {tensor_to_convert}')
print(f' Numpy Array(using tensor.numpy()): {np_from_tensor}\n')
print(f'Is memory shared back to NumPy? {tensor_to_convert.data_ptr() == np_from_tensor.ctypes.data}')
#%% Section 2: Essential Tensor Attributes/Properties
# Before we continue any further we first need to talk about a few important
# properties in tensors. These properties are what makes up a tensor.
# Every PyTorch tensor carries metadata that describes how it is stored and
# how PyTorch should treat it during computation.
# Among these properties/attributes, there are several important ones
# that we'll encounter over and over when we are working on training/inference of
# a neural network. The following 4, are used all the time:
#
# shape (or .size()): it describes the dimensions of the tensor.
# For example, a tensor with shape (3, 4) has 3 rows and 4 columns.
# Shape determines whether operations such as addition, multiplication,
# reshaping, and broadcasting are valid.
#
# dtype: specifies the type of values stored in the tensor (float32, int64,
# bool, etc). The data type affects memory usage, numerical precision,
# and which operations are permitted.
#
# device: indicates where the tensor resides: CPU, NVIDIA/AMD(through ROCM) GPU (CUDA),
# Apple Silicon GPU (MPS), and so on. Operations can only be performed
# between tensors on the same device.
#
# requires_grad: A boolean flag that tells PyTorch's Autograd engine whether to record
# operations on this tensor. During neural network training, model
# parameters usually have requires_grad=True so gradients can be computed
# automatically during backpropagation.
#
tensor = torch.randn(size=(3, 4), requires_grad=True)
print(f'Tensor Shape (attribute): {tensor.shape}')
print(f'Tensor Size (method): {tensor.size()}')
print(f'Tensor Data Type: {tensor.dtype}')
print(f'Tensor Device: {tensor.device}')
print(f'Requires Gradient? {tensor.requires_grad}')
# There are other attributes and methods, that are as useful/important
# and are extensively used during training/inference process.
# below we can see several of the mostly used ones:
## Total number of elements in the tensor.
# we use `numel()` or its alias `nelement()` To get the the
# total number of values in a tensor regardless of how
# those values are arranged accross dimensions.
# This is useful for a variety of reasons such as
# counting the total number of values in a tensor
# or checking whether a reshape operation is possible
# (ie. the number of elements must remain the same)
# another thing this method is used for is for counting
# model parameters.
print(f'Tensor elements count: {tensor.numel()}')
print(f'Tensor elements count(alias): {tensor.nelement()}')
# TODO Use properties instead of attributes? or keep using attribute?in python im more accustomed to attribute myself so thats why I used them here
# but properties seem better
# Number of dimensions (also known as the tensor's rank).
# This is one the most commonly used tensor properties
# its especially useful when we want to check whether
# a tensor has the expected number of dimension or when
# writting code that works with tensors of different ranks
# a scalar has 0 dimensions, a vector has 1, a matrix has 2, etc.
print(f'Number of dimensions (ndim): {tensor.ndim}')
print(f'Number of dimensions (dim method): {tensor.dim()}')
# Another property thats not as common but is as important is
# memory layout. The memory layout describes how tensor elements
# are stored.
# For almost all tensors(dense) we'll work with, this will is
# `torch.strided`.
# most dense tensors use `torch.strided`, which means PyTorch
# stores their elements using strides that describe how to move
# through memory. Other layouts, such as sparse layouts, exist
# for specialized use cases but are much less common.
# we dont think about memory layouts everyday when using Pytorch,
# but deal with them implicityly all the time. therefore
# understanding them helps us explain a lot of PyTorch's behavior.
# For example, why some operations are faster than others,
# why certain functions require a tensor to be contiguous, while others
# dont, or why an operation that looks simple might create a copy
# of the data behind the scenes instead of just returning a view.
# we'll almost always be working with `torch.strided`,but knowing
# what a tensor's memory layout is gives us a much better
# understanding/intuition of how PyTorch stores and accesses data.
# This becomes especially useful when we start optimizing code,
# or debug performance issues, or work with specialized tensor
# types later on.
print(f'Tensor layout: {tensor.layout}')
# Another property that we may encounter a lot especially when things
# go wrong, and see it in error messages, is the contiguous property
# of a tensor.
# A contiguous tensor is stored in one solid/uninterrupted block of memory.
# A lot of PyTorch operations are faster on contiguous tensors, and some
# operations (such as view()) require contiguity otherwise we face errors!
print(f'Is tensor contiguous? {tensor.is_contiguous()}')
# `grad_fn` property stores the operation that created this tensor.
# note that leaf tensors created directly by the user have `grad_fn=None`.
print(f'Gradient function(grad_fn): {tensor.grad_fn}')
# .grad property stores the computed gradients for *leaf* tensors,
# after we call `.backward()` to run backpropagation pass.
# the `tensor` is a leaf node but since `Backward()` hasn't been called yet,
# this is currently None.
print(f'Gradient currently stored: {tensor.grad}')
# `is_leaf` property tells us whether the tensor is directly created
# by us (like weights, biases, basically model parameters) or is an
# intermediate tensor that was created as a result of an operation.
# Pytorch only stores the gradients for leaf nodes -(nodes starting
# a graph) and it discards the gradients for non-leaf tensors.
# A leaf tensor is a normal tensor like any other, however the
# distinctions is there purely out of a technicality in having
# more efficient vram usage during traings.
# We need to calculate gradients for all tensors in a network
# that have requires_grad = True. simply storing all of the gradients
# for all tensors like that, leads to excessive amount of vram.
# Instead, In practice libraries such as PyTorch use a smart approach
# in which instead of storing the gradients for every single tensor
# in the graph they only store the gradients for the leaf nodes, that
# the optimizers require for tuning and optimization. the rest of the
# intermediate/non-leaf nodes (that get created as the result of operations
# involved) will have their gradients calculated dynamically/on the fly
# during backpropagation and then discared to save memory
# (for non-leaf nodes Pytorch instead records how the tensor was created,
# i.e. stores the function that yielded that tensor in grad_fn and
# any other piece of information thats required for computing gradients
# and computes the gradients using that during backprop)
#
# (so leaf tensors are the tensors we created that require gradients
# PyTorch stores .grad only for them by default because they are the
# tensors that optimizers update, while gradients of intermediate
# (non-leaf) tensors are computed on the fly and discarded to save
# memory.)
print(f'Is tensor a leaf node: {tensor.is_leaf}')
# When we run an operation and do a backward it gets filled
out = tensor + 1
# sidenote:
# since out is not a scalar, calling backward directly would raise an error
# like 'RuntimeError: grad can be implicitly created only for scalar outputs.'
# what we do during training is that we typically reduce the model's outputs
# to a single scalar loss (e.g. using sum or mean or a loss function, etc)
# this scalar serves as the starting point of the backpropagatopn process.
# Otherwise we need to provide the initial (upstream) gradients ourselves so
# that pytorch can then propagate this back and calculate all other gradients
# with respect to them.
# since we are not training, and dont need a loss, we can use sum() to provide
# the initial gradients. you may also see, some people, prefer a more verbose/explicit
# approach by sending all ones with the same shape as the output tensor doing backward()
# and send that as gradinets to backward() that is out.backward(gradient=torch.ones_like(out)!
# its the exact equivalent to out.sum().backward() since the gradient of a sum operation is ones!
# hence why we chose the shorter method using sum()
#
loss = out.sum()
loss.backward()
# or equivalently
# out.backward(gradient=torch.ones_like(out))
print('\nAfter forming a computation graph using tensor + 1')
print(f' Is tensor Leaf node? {tensor.is_leaf}')
print(f' Gradient for tensor: {tensor.grad}')
print(f'\n Is out leaf node? {out.is_leaf}')
print(f' Gradient for non-leaf node: {out.grad}')
print(f' Non-leaf grad_fn: {out.grad_fn}')
# sidenote:
# note that had we used an inplace operation on tensor, like tensor +=1
# we would have faced an error. we can not do inplace changes to leaf nodes
# that require grads.
#
# note2:
# To force Pytorch to store gradients for non-leaf(intermediate) nodes
# we can use retain_grad() function. note `retain_grad()` doesnt
# follow the the inplace naming convenion for tensors in using underscore(_) to
# denote inplace changes, so it actually does change the tensor property
# in place!( more explain in a moment)
# to query the grad retention status, we use the `retains_grad` property!
out2 = tensor + 1
out2.retain_grad()
out2.sum().backward()
print('\nForcing grad population for non-leaf node(out2) using retain_grad')
print(f' Is out2 leaf node? {out2.is_leaf}')
print(f' Does out2 retains gradients: {out2.retains_grad}')
print(f' Gradient for non-leaf node: {out2.grad}')
print(f' Non-leaf grad_fn: {out2.grad_fn}\n')
# Memory occupied by a single element.
# this method returns the size (amount of bytes) a dtype occupies
# for example, float32 occupies 4 bytes, float64 occupies 8 bytes,
# int64 occupies 8 bytes, and so on.
print(f'Element size (bytes): {tensor.element_size()}')
# we can use this to calculate the amount of memory a tensor's data buffer
# takes. note that there are more than just a simple data buffer in a tensor.
# PyTorch stores metadata for each tensor and theres also
# Python object overhead, gradients, or allocator bookkeeping as well so
# in reality the actual memory footprint is larger.
print(f'Total memory (bytes): {tensor.element_size() * tensor.numel()}')
# we can inspect a tensor's dtype directly (e.g. float32, int64, etc),
# but Pytorch also provides a conviniet method named is_floating_point()
#
# rather than checking for a specific floating-point dtype directly, this
# method returns True for any floating-point tensor (float16, bfloat16, float32,...)
# This is useful because many mathamatical operations and layers expect floating point inputs!
print(f'Is floating-point tensor? {tensor.is_floating_point()}')
# Also remember that tensor conversion methods always return new tensors.
# They never modify the original one inplace
# Here we convert the tensor from float32 (the default) to float64.
tensor_fp64 = tensor.double()
print("\nData type conversion:")
print(f'Original dtype: {tensor.dtype}')
print(f'Converted dtype: {tensor_fp64.dtype}')
print(f'Original tensor unchanged? {tensor.dtype == torch.float32}')
# sidenote:
# Most Pytorch `tensor` operations are not inplace, instead they return a new tensor
# and leave the original unchanged.
# By convention, tensor methods that modify a tensor's *contents* inplace end
# with a trailing underscore(_) such as tensor.add_, tensor.zero_, etc
#
# some methods such as retain_grad() modify the tensor's autograd behavior
# or its internal state (i.e. flags) rather than its data, so they do not
# necessarily follow this naming convention.
#
# so the underscore convention is about in-place tensor mutation only,
# not "any method that changes anything about the object"!
# so in a nutshell, methods with a trailing underscore like foo_() modify
# the tensor's data/storage in place.
# the others usually dont modify the *data*, but may very well still change
# metadata or autograd state like retain_grad().
# note detach_() is underscored because it changes autograd state inplace,
# like requires_grad_()
# out.requires_grad_(True) # modifies tensor state (underscore)
# out.add_(1) # modifies tensor data (underscore)
# out.copy_(tensor) # modifies tensor data (underscore)
# out.retain_grad() # modifies autograd behavior (no underscore)
#
# likewise, there are other *stateful* methods without underscores such as:
#
# tensor.share_memory_() # underscore (storage-related)
# module.cpu() # changes module state, no underscore
# module.cuda() # changes module state, no underscore
# module.train() # changes module state, no underscore
# module.eval() # changes module state, no underscore
#%%
#%% Section 4: Device Management & Custom Defaults
# 1.Accelerator
# So far we have learned how to create tensors, and all of them have been runing on
# the CPU. However, if you remember we pointed out early on, that one of the reasons
# we use Pytorch is that they have very efficient implementations that can run on
# GPUs! We are going to learn about just that and see how we can utlize one of PyTorch's
# biggest strengths, i.e. the ability to execute tensor operations on hardware accelerators,
# such as GPUs.
#
# Before we can use an accelerator, we first need to understand what they are
# and see whether one is available on our system/machine.
#
# sidenote:
# Why do we say "accelerator" instead of just "GPU"?
# We use the term accelerator instead of GPU because, unlike the early days of
# PyTorch-- when it only supported NVIDIA GPUs-- it now supports several kinds
# of hardware designed to speed up tensor operations. Its not just GPUs any more.
#
# Besides the CPU, which is the default device, PyTorch currently supports the
# following accelerator platforms also known as devices in Pytorch nomenclature:
#
# - CUDA (NVIDIA GPUs): The most common accelerator we'll encounter in PyTorch
# tutorials and industry. NVIDIA GPUs contain thousands of processing cores
# capable of executing many operations in parallel. This makes them significantly
# faster than CPUs for training and running neural networks.
#
# - MPS (Apple Silicon): Pytorch has added support for Apple Mac computers with
# an M-series chip recently, it can now use Apple's Metal Performance Shaders (MPS)
# backend to accelerate tensor operations on the integrated GPU.
#
# - ROCm (AMD GPUs): This is AMD's GPU platform. Using RoCM, supported AMD GPUs
# can accelerate PyTorch computations in nearly the same way CUDA does on NVIDIA hardware.
#
# - XPU (Intel GPUs): PyTorch also supports compatible Intel GPUs through the
# XPU device type. Under the hood it uses Intel's oneAPI software stack, but
# from our code we simply move tensors to the "xpu" device.
#
# Besides these devices, PyTorch also includes optimized libraries such as
# XNNPACK, MKL, and cuDNN. These are not separate devices. Instead, they optimize
# operations on the CPU or GPU behind the scenes.
#
# The nice thing is that, in most cases, our PyTorch code barely changes. We
# simply move our tensors (and later, our models) to whichever device is
# available, and PyTorch takes care of executing the computations there.
#
# 2. How do I know what accelerator my machine has?
# Before using an accelerator, we first need to determine which ones are
# available. This is how we would do that:
print(f"CUDA available: {torch.cuda.is_available()}")
print(f"MPS available: {torch.backends.mps.is_available()}")
print(f"XPU available: {hasattr(torch, 'xpu') and torch.xpu.is_available()}")
# On my machine, this prints:
#
# CUDA available: True
# MPS available: False
# XPU available: False
#
# Note if we had an AMD GPU with RoCm installed, we would still use
# `torch.cuda.is_available()`. More on that in a moment.
# Once we know which accelerator is available, we can begin working with
# devices.
# 3. Inspecting a tensor's device
# Every tensor knows which device it lives on. we can inspect it using the
# `.device` property.
print(f"Tensor device: {tensor.device}")
# On my machine, this prints 'cuda:0'!
#
# note that the output is `cuda:0` instead of simply `cuda`.
# The first part ('cuda') tells us the device type, while the number (0)
# identifies the specific accelerator being used.
#
# If we only care about the device type, we can use `.device.type` instead.
print(tensor.device.type)
# which prints 'cuda'!
#
# This is useful when we have multiple accelerators on our machine. For
# example, a workstation might contain two GPUs while a large training server
# may contain eight or more. PyTorch assigns each accelerator an index:
#
# cuda:0 # First GPU
# cuda:1 # Second GPU
# cuda:2 # Third GPU
# ...
#
# We'll revisit multiple GPUs later in this chapter.
#
# 4. Moving tensors between devices
# Knowing where a tensor lives is useful, but eventually we'll want to move
# tensors from one device to another.
# The recommended way to do this is with the `.to()` method.
# First, let's choose the best device available on our current machine.
if torch.cuda.is_available():
device = "cuda"
elif torch.backends.mps.is_available():
device = "mps"
elif hasattr(torch, "xpu") and torch.xpu.is_available():
device = "xpu"
else:
device = "cpu"
# Now we can move tensors to that device.
tensor = tensor.to(device)
print(f"Tensor device: {tensor.device.type}")
# PyTorch also provides for us with convenience methods such as .cuda() and .cpu().
tensor = tensor.cuda()
print(tensor.device.type)
# To move the tensor back to the CPU,
tensor = tensor.cpu()
print(tensor.device.type)
# Throughout this book we'll prefer .to(device) because it works regardless of
# whether we're using CUDA, MPS, XPU, or just the CPU.
#
# 5. Creating tensors directly on a device
# Instead of creating a tensor on the CPU and then moving it, we can create it
# directly on the desired/target device from the very begining during the instantiation!
tensor = torch.rand((2, 2), device=device)
print(tensor.device)
# If we already know the exact device we want, we can also specify it
# explicitly.
tensor = torch.rand((2, 2), device="cuda:0")
# PyTorch also allows us to specify the device index directly.
tensor = torch.rand((2, 2), device=0)
# Although this works, we generally prefer using the device variable from
# earlier because it keeps our code portable across different machines.
#
# 6. Working with multiple GPUs
# Some machines contain more than one GPU. If we want to know how
# many GPUs are available, we can use `torch.cuda.device_count()`
# To display every available GPU along with its name,
if torch.cuda.is_available():
print(f"Available GPUs: {torch.cuda.device_count()}")
for i in range(torch.cuda.device_count()):
print(f"GPU {i}: {torch.cuda.get_device_name(i)}")
# To inspect a GPU's compute capability, we use `torch.cuda.get_device_capability(0)`
# If we want to use a specific GPU, we simply specify its index.
# since I only have one GPU, I use 0!
device = torch.device("cuda:0")
tensor = torch.randn(3, 3, device=device)
# If no index is specified, like `device = "cuda"` PyTorch automatically uses
# the first GPU `(cuda:0)`.
#
# we'll be using only a single accelerator throughout this book since the
# overwhelming majority of PyTorch code is identical regardless of how many
# GPUs are installed.
#
# sidenote:
# Not every accelerator platform supports multiple devices.
# CUDA/ROCm and XPU can expose multiple accelerators, whereas Apple's MPS
# currently exposes only a single device.(As of writing this in PyTorch 2.12)
#
# 7. CUDA vs ROCm
# One interesting detail is that ROCm is not a separate device type inside
# PyTorch. Even on AMD GPUs, PyTorch still uses the "cuda" device interface.
#
# For example,
device = "cuda"
# may refer to either an NVIDIA GPU (CUDA), or an AMD GPU (ROCm) depending
# on which build of PyTorch is installed.
#
# This is because PyTorch's GPU backend was originally designed around CUDA,
# and the ROCm backend implements the same programming interface for compatibility.
#
# If you'd like to know which backend your installation was built with, you can
# inspect the version information.
print(torch.version.cuda)
print(torch.version.hip)
# On my machine, this prints
#
# 13.0
# None
#
# which means that I'm using the CUDA backend. On a ROCm installation,
# `torch.version.cuda` would be None while `torch.version.hip` would contain the
# ROCm version.
#
# There are many more useful functions available in the `torch.cuda` module.
# We'll introduce more of them later in the book, but it's worth browsing the
# documentation if you're curious.
#
# Since pytorch 2.0 we have a `device` context manager which makes our lives easier
# by assigning a specified device to all *new* tensors that are created inside that
# context manager scope.
# that is any tensors-inlcuding models (torch modules)-we create inside that context
# manager will be assigned that device!
print('\nUsing context manager to set device(cuda)')
with torch.device('cuda'):
# our model can be as simple as a linear layer (we'll learn about them
# in more details in future chapters)
model = torch.nn.Linear(4,1)
dummy_input = torch.rand(size=(1,4))
dummy_output = model(dummy_input)
# note if we grab one of the parameters and check its device it shows cuda!
print(f' Model.device: {next(model.parameters()).device}')
print(f' Dummy_input.device: {dummy_input.device}')
print(f' Dummy_output.device: {dummy_output.device}')
# note that, as of now (Pytorch2.12), `torch.device` context manager does not change
# the device for tensors that already exist. For those we still need to use `.to()`
# to move the data to a specific device.
# you have probably noticed that all tensors we create by default, have been on the cpu.
# we can change this behavior and make, by default, all tensors to be on a
# specific device like cuda globally!
# we use torch.set_default_device() for this purpose:
print('\nMaking CUDA the default device')
torch.set_default_device('cuda')
# now from now on, all tensors, modules, etc will have their device='cuda' by default
dummy_input = torch.rand(size=(1,4))
print(f' dummy_input.device: {dummy_input.device}')
# since pytorch 2.3.0 we can also get the current default device
# using `torch.get_default_device()`
version = int("".join(torch.__version__.split('.')[:2]))
if version >23:
print(f"\nthe default device is now '{torch.get_default_device().type}'")
else:
print('pytorch 2.3.0+ is needed for this feature.')
# revert back to CPU
torch.set_default_device("cpu")
#%% Section 6: Dimension Manipulation (Shape, Reshape, View, Resize, Squeeze, Unsqueeze & Permute)
# The tensors we have experimented with so far had a pre-specified
# shape and we didn't need to change them. However, this is not always the case
# As we'll see shortly, different PyTorch operations and neural network layers
# expect tensors to have specific dimensions. As a result, we'll frequently need
# to reshape tensors, add or remove dimensions, or rearrange the order of their
# axes without changing the underlying data.
# This can range from simply adding a batch dimension into an input tensor during
# inference, reshape/merge the dimensions in an input to make it suitable for the
# next layer like an LSTM or swap axis (permute) when implementing an attention module.
#