<jupyter_start><jupyter_text>Copyright 2019 The University of Birmingham Positron Imaging Centre.Licensed under the GNU License, Version 3.0 (the "License"). Python Tutorial For engineers and other people who just want a computer to do their work.UoB Positron Imaging Centre's GitHub  1. IntroductionThe aim of this tutorial is to get you up to speed with Python programming. Besides the basics, it should teach you how to use it effectively for usual programming tasks, like creating dynamic arrays, iterating them, saving them as CSV, etc.It assumes a very basic understanding of programming languages. If you've written more than 100 lines of code in, say, MATLAB, you should be fine.  1.1. The Python Programming LanguagePython is a general-purpose, interpreted programming language. It aims to be as simple and clear as possible. The main design philosophy of it is:> There should be one — and preferably only one — obvious way to do it.Because it is an interpreted programming language, Python code can run in a variety of environments:- As a script.- As functions or classes.- As a command-line (or REPL - Read-Eval-Print-Loop).- As mixed text and code cells in a Jupyter Notebook (such as this one).  1.2. The 'pip' Package ManagerIt is a very good idea to encapsulate code into packages, modules, and the like. Thankfully, python has a pretty smart package manager called 'pip' that takes care of installing and updating different packages.Let's install the Numerical Python (NumPy) package. It provides classic MATLAB-like array functionality to Python, while being a bit more extensible. You will live and die by it.Run the next cell. You will see the output right under it.<jupyter_code># Python comments start with a hashtag
# pip is a system command. To run shell-like commands
# in a Notebook environment, put an exclamation mark (!)
# in front of it
!pip install numpy<jupyter_output>Requirement already satisfied: numpy in c:\programdata\anaconda3\lib\site-packages (1.16.4)<jupyter_text>1.3 Using ModulesOnce you have a module installed, you can import it inside your Python code (be it script, function, sandwich toaster, etc.):<jupyter_code># A simple import:
import numpy
print( numpy.array([1, 2, 3]) )

# An import that creates a shorthand:
import numpy as np
print( np.array(['a', 'simple', 'array', 5.13]) )

# Or you can import a specific module from inside the package:
from numpy import array
print( array([1., 3.14, -2.718]) )<jupyter_output>[1 2 3]
['a' 'simple' 'array' '5.13']
[ 1.     3.14  -2.718]<jupyter_text>2. Basic ConstructsNow we'll start actually programming in Python! 2.1. Basic Data StructuresThe simplest (i.e. most usual) data types that you will use are:- `int` : normal integers. Python integers have a variable size: they are as big as your memory permits!- `float` : real numbers. For the C++ nerds: it is equivalent to a double-precision floating-point number.- `str` : character strings.<jupyter_code># Integers:
print( 1 + 2 + 132132413424312413241324312 // 5) # Modulo division is //, not %

# Real numbers:
print( 3.14 *  2. ** 2 ) # Raise a number to a power using **

# Strings:
print( "I am a" + ' "concatenated" string.' )<jupyter_output>26426482684862482648264865
12.56
I am a "concatenated" string.<jupyter_text>2.1.1. ListsThe `list` is a built-in data structure. Unlike a MATLAB array, it is a variable-length container. This makes it very handy when we are dynamically allocating / reading in data:<jupyter_code># Define a list with []. Only use semicolons when writing
# multiple commands on the same line.
x = [1, 'hey', 2.718] ; print(x)

# Dynamically add elements to it:
x.append(42) ; print(x)

# It is a list, not a mathematical array!
print( [1, 2, 3] + [4, 5, 6] )
print( [-1, 1] * 3 )<jupyter_output>[1, 'hey', 2.718]
[1, 'hey', 2.718, 42]
[1, 2, 3, 4, 5, 6]
[-1, 1, -1, 1, -1, 1]<jupyter_text>2.2. Conditionals and Loops:In Python we have the usual `if / elif / else` statements and the `for / while` loops.Python is a whitespace-delimited language. This means that blocks of code are delimited by the indentation level, rather than curly braces or start/end statements.<jupyter_code># Conditionals:
x = 3.14

# The start of a block is usually denoted with a colon:
if type(x) == int:
    print("x is an integer!")
elif type(x) == float:
    print("x is a float!")
else:
    print("well who knows?")
# Loops:

# The while loop is exactly like in other programming languages
i = 0
while i < 4:             # Note the colon! 
    print("while")
    i += 1
    
print() # Print a newline
    
# The for loop, on the other hand, might be a bit different.
# It iterates through the elements of an iterable, like a list:
data = [1, 2, 3]
for i in data:
    print(i)
    
print()
    
# range() is an iterable
for i in range(5):
    print(i)<jupyter_output>while
while
while
while

1
2
3

0
1
2
3
4<jupyter_text>2.3. FunctionsFunctions in Python have a few extra features:- Positional arguments.- Keyword arguments.- Default values.<jupyter_code># A simple function with one positional argument.
def double(x):       # Don't forget the colon!
    x = x * 2        # There are no braces or start/end, only tabs/spaces
    return x

print( double(3.14) )
    
# A function with one positional argument and one optional keyword argument.
def add(x, value = 10):           # value is 10 by default
    return x + value

print( add(2, value = 40) )       # keyword argument
print( add('Hello', ' world!') )  # positional arguments
print( add(6) )                   # default value<jupyter_output>42
Hello world!
16<jupyter_text>2.4. ClassesA `class` is a powerful way to encapsulate data ("properties") and functions ("methods"):<jupyter_code># Define a simple class:

class Employee:
    
    # This is the constructor, which is called when instantiating the class.
    # The `self` argument is supplied as the first parameter to every method.
    def __init__(self, name, surname, age):
        # We 'attach' the parameters to the class instance via self.
        self.name = name
        self.surname = surname
        self.age = age
        
        # Create new class properties
        self.wage = 6.15
        self.status = "poor"
        
    # Define a method, supplying the default `self`
    def show(self):
        print('Employee data:')
        print('- name: ' + self.name + ' ' + self.surname)
        print('- age: ' + str(self.age) )
        print('- wage: ' + str(self.wage) )
        print('- status: ' + self.status)
        
        
# Instantiate the class, providing the 3 positional arguments of __init__
# Note that `self` is omitted
worker = Employee('John', 'Doe', 20)

# We can access the class properties with dots:
print( worker.name + ' ' + worker.surname )

# We can call a method, omitting `self` again:
worker.show()

# We can change the class properties just as we access them:
worker.age += 1
worker.wage = 7.7
worker.status = "still poor"
worker.show()<jupyter_output><NAME>
Employee data:
- name: <NAME>
- age: 20
- wage: 6.15
- status: poor
Employee data:
- name: <NAME>
- age: 21
- wage: 7.7
- status: still poor<jupyter_text>Don't worry if you are not sure about what the point of a `class` is. It is a more advanced concept that only becomes useful once your project increases in size. The most important things to understand are:- A class is a container for properties and methods.- You "instantiate" a class and it will remember the properties you set.- You access the properties and methods of instances of classes using a dot.  3. NumPy BasicsNow let's extend our Python arsenal with some NumPy-fuelled mathematical power! The easiest way to explain it is by example:  3.1. NumPy Array Creation and Basic PropertiesStarting from lists:<jupyter_code>import numpy as np

# From a list:
x = np.array([1, 2, 3])
print(x)          # The whole array: [1, 2, 3]
print(type(x))    # Variable type: numpy.ndarray
print(x.dtype)    # Type of the elements of the array: int32
print(x.shape)    # Shape of array: (3)
print(x[0])       # Element at index 0. Yes, Python is 0-indexed, like a proper language.

print('----------------')

y = np.array( [ [1.1, 2.2] , [3.3, 4.4] ] )
print(y)
print(y.dtype)
print(y.shape)
print(y[0][1])
print(y[0, 1])<jupyter_output>[1 2 3]
<class 'numpy.ndarray'>
int32
(3,)
1
----------------
[[1.1 2.2]
 [3.3 4.4]]
float64
(2, 2)
2.2
2.2<jupyter_text>Using NumPy functions:<jupyter_code>import numpy as np

# Using numpy functions:
x = np.zeros(5) ;        print(x)
y = np.ones( (2, 3) ) ;  print(y)  # (2, 3) is a tuple
z = np.eye(2) ;          print(z)<jupyter_output>[0. 0. 0. 0. 0.]
[[1. 1. 1.]
 [1. 1. 1.]]
[[1. 0.]
 [0. 1.]]<jupyter_text>3.2. NumPy Array IndexingThis part is very similar to MATLAB's arrays.<jupyter_code>import numpy as np

x = np.arange(10)
print(" Original array: ")
print(x)

print("\n Selecting slices: ")
print( x[3:] )   # Everything after index 3
print( x[3:6] )  # Indexes 3, 4, 5. It excludes 6.

print("\n Logical indexing: ")
print( x > 5 )
print( x[ x > 5 ] )
import numpy as np

x = np.arange(18).reshape(3, 6)
print(" Original array: ")
print(x)

print("\n Slicing rows: ")
print( x[0] )
print( x[0, :] )

print("\n Slicing columns: ")
print( x[:, 3] )

print("\n x[:, 2] is not equivalent to x[:][2] !")
print(x[:, 2])    # Selects all rows at column 2
print(x[:][2])    # Selects the whole array, THEN selects row 2<jupyter_output>Original array: 
[[ 0  1  2  3  4  5]
 [ 6  7  8  9 10 11]
 [12 13 14 15 16 17]]

 Slicing rows: 
[0 1 2 3 4 5]
[0 1 2 3 4 5]

 Slicing columns: 
[ 3  9 15]

 x[:, 2] is not equivalent to x[:][2] !
[ 2  8 14]
[12 13 14 15 16 17]<jupyter_text>3.3. Operations on NumPy ArraysInspired by MATLAB, extended by NumPy (TM). Here are some basic operations; some other, more usual things are illustrated towards the end of the tutorial.<jupyter_code>import numpy as np

x = np.ones( (3, 3) )
y = np.eye(3)

# Element-wise operators:
print(" Element-wise addition: ")
print(x + y)

print("\n Element-wise multiplication: ")
print(x * y)

# Scalar operations:
print("\n Scalar multiplication: ")
print(x * 2)

print("\n Add a scalar to the whole array: ")
print(x + 41)
import numpy as np

x = np.random.random( (3, 3) ) * 10   # create a 3x3 array with random values from (0, 1)
y = np.ones( (3, 3) )

# Matrix-wise operations:
print(" Matrix-wise multiplication:")
print( np.matmul(x, y) )

print("\n Matrix inverse:")
print( np.linalg.inv(x) )
import numpy as np

x = np.arange(3)
y = np.arange(9).reshape(3, 3)

print(" Dot product of vectors: ")
print(np.dot(x, x))
print(x @ x)

print("\n Dot product of vector and matrix: ")
print(x @ y)
import numpy as np

x = np.arange(9).reshape(3, 3)

print(" Functions are applied element-wise by default: ")
print(np.sin(x))<jupyter_output>Functions are applied element-wise by default: 
[[ 0.          0.84147098  0.90929743]
 [ 0.14112001 -0.7568025  -0.95892427]
 [-0.2794155   0.6569866   0.98935825]]<jupyter_text>4. Matplotlib BasicsNow let's plot some basic things using Matplotlib. The quickest way is using MATLAB-style 'stateful' plotting:<jupyter_code>import numpy as np
import matplotlib.pyplot as plt

x = np.linspace(0, 2, 100)

plt.plot(x, x, label='linear')
plt.plot(x, x**2, label='quadratic')
plt.plot(x, x**3, label='cubic')

plt.xlabel('x label')
plt.ylabel('y label')

plt.title("Simple Plot")

plt.legend()

plt.show()<jupyter_output><empty_output><jupyter_text>A more programmatic way would be to use individual figures and axes in the object-oriented style:<jupyter_code>import numpy as np
import matplotlib.pyplot as plt

x = np.arange(0, 10, 0.1)
y = np.sin(x)

fig, ax = plt.subplots() # Creates a subplot with one figure by default

ax.plot(x, y)
plt.show()<jupyter_output><empty_output><jupyter_text>When using subplots, the object-oriented style becomes more useful:<jupyter_code>import numpy as np
import matplotlib.pyplot as plt

x = np.arange(0, 10, 0.5)
y = np.sin(x)

# Create a figure and an *array of axes*
fig, ax = plt.subplots(1, 2)

print("Created a list of axes:")
print(ax)

ax[0].plot(x, y, 'go')
ax[1].plot(x, y, marker = 'x', color = 'r')
plt.show()<jupyter_output>Created a list of axes:
[<matplotlib.axes._subplots.AxesSubplot object at 0x0000014670D0DA20>
 <matplotlib.axes._subplots.AxesSubplot object at 0x0000014670802EF0>]<jupyter_text>5. Plotly BasicsPlotly is JavaScript-based plotting engine that creates beautiful figures. They can be exported as interactive webpages and can be more efficient than Matplotlib in handling big datasets.Plotly is not a 'standard' package like NumPy or Matplotlib. Let's install it using `pip`:<jupyter_code># Again, use an exclamation mark in front of system commands:
!pip install plotly<jupyter_output>Requirement already satisfied: plotly in c:\programdata\anaconda3\lib\site-packages (4.1.1)
Requirement already satisfied: six in c:\programdata\anaconda3\lib\site-packages (from plotly) (1.12.0)
Requirement already satisfied: retrying>=1.3.3 in c:\programdata\anaconda3\lib\site-packages (from plotly) (1.3.3)<jupyter_text>Plotly is a high-level, declarative charting library. This means that every setting of the plot can be accessed and set as a property of the instance.<jupyter_code># If run in JupyterLab (as opposed to Jupyter Notebook), check settings > Enable Extensions Manager

import plotly.graph_objects as go
import numpy as np

data_x = np.random.random(100) * 10
data_y = np.random.random(100) * 10
data_z = np.random.random(100) * 10

fig = go.Figure()

# A trace is an individual graph. Multiple traces can be
# displayed on a single figure.
trace = go.Scatter3d(
    x = data_x,
    y = data_y,
    z = data_z,
    mode = 'markers',
    marker = dict(
        color = data_z,   # Encode z as a colorbar
    )
)

fig.add_trace(trace)
fig.show()<jupyter_output><empty_output><jupyter_text>6. ExtraThis section is entirely optional. It shows some more advanced, but only sometimes used features of Python. I recommend you skim through it, such that you are at least familiar with the concepts. If the need for them arises, you'll know of their existence.  6.1. Other Data StructuresSome other data structures are:- `tuple` : ordered, unchangeable collection.- `dict` : a dictionary, mapping keys to values. It has very efficient data access.- `set` : a set, like the mathematical definition: no repeating values.- `None` : a special value that signifies an undefined or unset variable. Can be used as a default function parameter.Each of them defines some special operations:<jupyter_code># Tuple:
x = ("Sam", 3)
print('Tuple x: ', x)

# Dictionaries can be defined in two ways.
y = dict(sam = 5, john = 18)
z = {'sam': 5, 'john': 18}
print('\nDict y: ', y)
print('Dict z: ', z)

# Dictionaries have a special operation to check the existence of a key:
print("Key 'sam' in dict y: ", ('sam' in y))
print("Key 'peter' in dict y: ", ('peter' in y))

# Sets
t = {1, 2, 3, 1, 1}    # repeated entries are ignored
print("\nSet 't': ", t)

# The existence of an element in a set is checked with 'in', again:
print("Element 1 in set t: ", (1 in t))
print("Element 5 in set t: ", (5 in t))
# Using None as default function parameter:
def database_query(query, database = None):
    # Use 'is' for checking specifically for None,
    # rather than False
    if database is None:
        print("Database is unset!")
        return
    if database is not None:
        print("Database is ", (database))
        query
        
database_query("select")
database_query("select", database = "test")<jupyter_output>Database is unset!
Database is  test<jupyter_text>6.2. DocumentationThe documentation of a function / class / module can be accessed in a couple of ways:<jupyter_code>import numpy as np

# Short documentation:
? np.linalg.inv

# Long documentation:
#?? np.linalg.inv

# Using the __doc__ method
#print(np.linalg.inv.__doc__)<jupyter_output><empty_output><jupyter_text>You can easily document your own code, and Python will automatically generate the documentation strings:<jupyter_code># Show how documentation works. Triple quotes are comments that
# span multiple lines.

def doc_test(x):
    '''The first line is the short documentation. This is a quick description of the function.
    
    This string is only shown when adding one question mark in front of the function. The
    implementation below is only shown when adding two question marks.
    '''
    
    print("This is the actual implementation of the function, only shown with ??.")
    
# Only check one at a time:
? doc_test
#?? doc_test<jupyter_output><empty_output><jupyter_text>6.3. Python Virtual EnvironmentsOnce you start building larger applications, it is important to have a reliable programming environment. What happens if:- Your app only works on one version of the Python interpreter.- Your app only works with older versions of some modules.The set of Python and modules' versions forms a "Virtual Environment". You can 'freeze' the state of your Virtual Environment and know that what you are developing surely works on those versions.This is the main idea. There are quite a few ways of managing multiple virtual environments. I will let you read about them once their need arises.  7. Usual how-toThis is the final chapter of this tutorial, congratulations!I will show you the 'easy' (or 'right', following Python's philosophy) way of doing a few usual tasks. This should save you a few trips to StackOverflow and NumPy's documentation.  7.1. Formatting StringsWe frequently need to output data in a nice format. Thankfully, Python has some nice ways of printing stuff:<jupyter_code># Format strings to output data nicely:
import numpy as np

num = 12
lst = [1, 2, 3]
vec = np.array(lst)

# Supply multiple arguments to print:
print("Number: ", num, "\nList: ", lst, "\nVector: ", vec, "\n")

# Use the format identifier. Add curly braces {} inside a string,
# then supply the variables to format:
print("Number: {}\nList: {}\nVector: {}".format(num, lst, vec))<jupyter_output>Number:  12 
List:  [1, 2, 3] 
Vector:  [1 2 3] 

Number: 12
List: [1, 2, 3]
Vector: [1 2 3]<jupyter_text>7.2. Iteration + List ComprehensionsIf you start using C++-style for/while loops, stop and think if there isn't a more concise (i.e. better) way of doing what you want. Python has some nice features for different types of iteration:<jupyter_code># Iterate using an index range:
x = 0
for i in range(5):
    x += 1
    
print("Looped using range: x = {}\n".format(x))

# Iterate through the elements of a list, while also
# having the index:
data = ["apple", "pears", "oranges"]
for index, elem in enumerate(data):
    print("At index {}, we have {}".format(index, elem))
print() # For the newline<jupyter_output>Looped using range: x = 5

At index 0, we have apple
At index 1, we have pears
At index 2, we have oranges<jupyter_text>List comprehensions provide a concise and powerful way of generating or accessing iterable data.The main syntax is:```python[ expression for element in iterable if condition ]```<jupyter_code># For-comprehensions are powerful ways of accessing data
data = "The world is definitely not deterministic".split()
print(data)

# Collect words whose length is > 4
new_data = [elem for elem in data if len(elem) > 4]
print(new_data)

# You can also use list comprehensions to generate data
squares = [x ** 2 for x in range(4)]
print(squares)
# List comprehensions are great for concise data extraction:
# Generate a list of arrays:
import numpy as np

data = [np.arange(10).reshape(2, 5)] * 4
print(data)

# Extract the second row of every array
# and create a new NumPy array
rows = np.array( [arr[1] for arr in data] )
print(rows)<jupyter_output>[array([[0, 1, 2, 3, 4],
       [5, 6, 7, 8, 9]]), array([[0, 1, 2, 3, 4],
       [5, 6, 7, 8, 9]]), array([[0, 1, 2, 3, 4],
       [5, 6, 7, 8, 9]]), array([[0, 1, 2, 3, 4],
       [5, 6, 7, 8, 9]])]
[[5 6 7 8 9]
 [5 6 7 8 9]
 [5 6 7 8 9]
 [5 6 7 8 9]]<jupyter_text>7.3. Dynamic Array CreationWe frequently need to create arrays dynamically, without knowing how much there will be (e.g. reading lines from a file). Python `list`s  have variable size, but are not great for maths, while NumPy `array`s are very powerful but are not resizable. How can we achieve the best of both worlds? Combining them, of course:<jupyter_code># First append stuff to a dynamic list
import numpy as np


data = []
# Random number of iterations:
for i in range(int(2 + 10 * np.random.random())):
    data.append(np.ones(5) * i)

print(data) # list of 1D arrays
# After reading stuff into a list, transform it into a numpy array
data = np.array(data)

print(data) # 2D array<jupyter_output>[array([0., 0., 0., 0., 0.]), array([1., 1., 1., 1., 1.]), array([2., 2., 2., 2., 2.]), array([3., 3., 3., 3., 3.]), array([4., 4., 4., 4., 4.])]
[[0. 0. 0. 0. 0.]
 [1. 1. 1. 1. 1.]
 [2. 2. 2. 2. 2.]
 [3. 3. 3. 3. 3.]
 [4. 4. 4. 4. 4.]]<jupyter_text>7.4. Saving and Loading DataWe frequently need to load data from a file, do something to it, then save it somewhere else. In engineering, we most often handle big piles of numerical data that is usually saved as in a CSV (comma-separated-values) format. Again, Python has a few tricks up its sleeve.Reading or writing files line by line is laborious and oftentimes slow. NumPy has some optimised functions for this:<jupyter_code>import numpy as np

# Generate some random data
data = np.random.random( (10, 5) ) * 10
print("Generated data: \n", data)

# Save the data as .csv using savetxt
filepath = "pretty_data.csv"
np.savetxt(filepath, data)

# We can afterwards load the data, with a few options
loaded_data = np.loadtxt(filepath, skiprows = 2, max_rows = 3)
print("\nLoaded data: \n", loaded_data)<jupyter_output>Generated data: 
 [[2.71049104 6.3367893  4.36217195 4.7815844  1.85593233]
 [4.17322142 8.76640863 0.73038891 8.64451825 5.68126144]
 [6.86910278 2.37510954 7.76814919 7.50355948 5.54861531]
 [5.66206815 7.08853646 0.22717545 8.40435694 8.51447847]
 [3.88277707 6.8403091  0.09159559 6.46554123 1.48512294]
 [1.70500375 5.22460072 4.04647353 4.73514738 3.00945845]
 [2.24412051 6.19929258 1.5546722  2.7512191  8.14066144]
 [8.60311202 1.67685698 3.64739168 6.56183256 4.09570612]
 [2.64212331 8.05196694 5.43681597 2.00803021 3.016985  ]
 [1.51609635 1.34269474 3.58943119 1.57664596 6.25381503]]

Loaded data: 
 [[6.86910278 2.37510954 7.76814919 7.50355948 5.54861531]
 [5.66206815 7.08853646 0.22717545 8.40435694 8.51447847]
 [3.88277707 6.8403091  0.09159559 6.46554123 1.48512294]]<jupyter_text>Sometimes the input file has an ugly format (e.g. variable number of columns) and the NumPy `loadtxt` function cannot be used. This can happen with underdeveloped simulation packages, for example. In these cases, the best approach is to create a parser Python function, which reads the data line by line, formats it nicely into a NumPy array, then saves it using `savetxt`. The initial step is slow, but following iterations can just use the quick `loadtxt` function.<jupyter_code># This is an example of a badly-formatted csv file:
!type ugly_data.csv
# Read in ugly data, then put it into a numpy array, then save it
import csv
import numpy as np

nice_data = []
with open("ugly_data.csv", "r") as f:
    reader = csv.reader(f, delimiter=",")
    for i, line in enumerate(reader):
        print( 'line[{}] = {}'.format(i, line) )
        nice_data.append(np.array(line[0:2], dtype = float))
        
nice_data = np.array(nice_data)
print("\nNice data: \n", nice_data)

np.savetxt("nice_data.csv", nice_data)<jupyter_output>line[0] = ['0', ' 1', ' 2', ' 3', ' 4']
line[1] = ['0', ' 1', ' "hello"', '']
line[2] = ['2', ' 3', ' "bad"', '']
line[3] = ['5', ' 6', ' "data"']

Nice data: 
 [[0. 1.]
 [0. 1.]
 [2. 3.]
 [5. 6.]]<jupyter_text>7.5. SortingSorting data is terribly common. NumPy has one of the best sorting functions available, called TimSort (read about it, it's great).A common circumstance is when we want to sort the rows of an array based on one of the columns (e.g. time from a simulation).<jupyter_code># Some typical sorting
import numpy as np

vec = np.random.random(10) * 10
print("Vector of random values between 0 and 10: \n", vec)
print("Sorted: \n", np.sort(vec))


arr = np.random.random((7, 5)) * 10
print("\nArray of random values between 0 and 10: \n", data)

print("\nNumPy arrays have the .argsort function. We can use it on, say, column 3: \n", data[:, 2].argsort())
print("Those are the indices that the elements should have such that the vector would be sorted.")

print("\nUsing argsort and logical indexing, we can sort an array based on, say, column 3: \n", data[ data[:, 2].argsort() ])<jupyter_output>Vector of random values between 0 and 10: 
 [5.19037483 4.20096949 3.17088224 5.44006199 5.63871278 5.76790854
 0.21940061 2.53994284 9.29475925 4.92069733]
Sorted: 
 [0.21940061 2.53994284 3.17088224 4.20096949 4.92069733 5.19037483
 5.44006199 5.63871278 5.76790854 9.29475925]

Array of random values between 0 and 10: 
 [[2.71049104 6.3367893  4.36217195 4.7815844  1.85593233]
 [4.17322142 8.76640863 0.73038891 8.64451825 5.68126144]
 [6.86910278 2.37510954 7.76814919 7.50355948 5.54861531]
 [5.66206815 7.08853646 0.22717545 8.40435694 8.51447847]
 [3.88277707 6.8403091  0.09159559 6.46554123 1.48512294]
 [1.70500375 5.22460072 4.04647353 4.73514738 3.00945845]
 [2.24412051 6.19929258 1.5546722  2.7512191  8.14066144]
 [8.60311202 1.67685698 3.64739168 6.56183256 4.09570612]
 [2.64212331 8.05196694 5.43681597 2.00803021 3.016985  ]
 [1.51609635 1.34269474 3.58943119 1.57664596 6.25381503]]

NumPy arrays have the .argsort function. We can use it on, say, column 3: 
 [4 3 1 6 9 7 5 0 8[...]
{'fraction_non_alphanumeric': 0.11596291679650982, 'fraction_numerical': 0.07825646618884388, 'mean_word_length': 3.8467056824617707, 'pattern_counts': {'":': 0, '<': 91, '<?xml version=': 0, '>': 97, 'https://': 0, 'lorem ipsum': 0, 'www.': 0, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 0, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'chain_length': '26', 'dir': 'jupyter-structured-clean-dedup', 'id': '483487', 'license': 'MIT', 'n_tokens_mistral': 9951, 'n_tokens_neox': 8530, 'n_words': 3441, 'path': 'python_tutorial/Python-Tutorial.ipynb', 'repo_name': 'uob-positron-imaging-centre/Tutorials', 'size': '25678'}