This is my first python paper!¶

Section 1¶

In [1]:
import numpy as np
In [2]:
x = [5, 2, 3]
type(x)
# list

y = ["a", "b", "c"]
z = [True, False, False, True]
In [3]:
x[0]
Out[3]:
5

::: {.callout-warning} Watch out! Python indexing starts at 0, not 1 as in R! :::

In [4]:
y[0]   # 'a'   <- first element
y[1]   # 'b'   <- second element
Out[4]:
'b'

Lists: Printing, Concatenating, Coercing¶

In [5]:
print("The first value in y is '", y[0], "'.", sep="")
# The first value in y is 'a'.

"The first value in y is " + y[0] + "."
# 'The first value is y is a.'

str(x[0])   # '1'  -- coerce a number to a string
The first value in y is 'a'.
Out[5]:
'5'

A list can build a sequence with range, and can mix types freely:

In [6]:
u = list(range(0, 10, 2))   # [0, 2, 4, 6, 8]
v = [True, 0, "whatever", [1, 2]]
type(v[2])   # str
Out[6]:
str

Lists: Multiple Assignment and Editing¶

In [14]:
t, u, v = 3, [4, 5], "hello"

tuv = [t, u, v]
tuv[2] = "good-bye"
In [16]:
tuv
Out[16]:
[3, [4, 5], 'good-bye']

Important contrast with R: the * operator on a list does not do entrywise arithmetic --- it repeats the list!

In [17]:
x * 2
# [1, 2, 3, 1, 2, 3]
Out[17]:
[5, 2, 3, 5, 2, 3]

This is one of several signs that lists are not built for numerical computing --- that's what NumPy arrays are for.

The Python Tuple¶

A tuple is like a list, but immutable --- it cannot be edited after creation. Built with parentheses:

In [18]:
salutations = ("hey", "hi", "ahoy", "sup")
salutations[0]     # 'hey'

list(salutations)  # convert to a list
Out[18]:
['hey', 'hi', 'ahoy', 'sup']
  • Trying to edit a tuple's entry raises an error
  • Immutability makes tuples more memory-efficient
  • We will mostly see tuples used as arguments to functions (e.g. specifying the shape of an array)

The Python Dictionary¶

A dictionary stores key--value pairs, accessed by key rather than by position:

In [19]:
stat_540 = {'nstudents'  : 29,
            'time'       : '2:20 - 3:35 pm',
            'days'       : ['Tue', 'Thu'],
            'instructor' : 'Dr. Huang'}

stat_540['time']    # '1:15 - 2:30 pm'
Out[19]:
'2:20 - 3:35 pm'

Values can be edited by key:

In [20]:
stat_540['instructor'] = 'Dr. Ho'

For most of our statistics and data-science work, we will rely much more on NumPy arrays than on lists or dictionaries.

Outline¶

  1. Why Python? Getting oriented
  2. Basic Python objects: lists, tuples, dictionaries
  3. NumPy arrays: creating, indexing, slicing
  4. Array attributes, reshaping, and combining arrays
  5. Arithmetic and summary statistics on arrays
  6. Practice

Creating NumPy Arrays¶

NumPy arrays are more like R's vectors and matrices: entries must all be the same type.

In [4]:
import numpy as np

x = np.array([0, 1, 2])
type(x)   # numpy.ndarray
Out[4]:
numpy.ndarray

Building sequences, similarly to seq() in R:

In [22]:
seq  = np.arange(-1, 1, 1/4)   # [start, stop), step
seq2 = np.linspace(0, 1, 21)   # 21 equally-spaced points on [0,1]
print(seq)
print(seq2)
[-1.   -0.75 -0.5  -0.25  0.    0.25  0.5   0.75]
[0.   0.05 0.1  0.15 0.2  0.25 0.3  0.35 0.4  0.45 0.5  0.55 0.6  0.65
 0.7  0.75 0.8  0.85 0.9  0.95 1.  ]

Mixed types get upcast (e.g. integers become floats) so the array stays a single type.

Creating Arrays from Scratch¶

In [23]:
np.zeros(10, dtype=int)     # length-10 array of zeros
np.ones((3, 5))             # 3x5 array of ones
np.full((3, 7), 4)          # 3x7 array, all entries = 4
np.eye(3)                   # 3x3 identity matrix
np.diag(np.ones(5))         # 5x5 diagonal / identity matrix

np.random.random((3, 3))          # Uniform(0,1) entries
np.random.normal(0, 1, (3, 3))    # Normal(0,1) entries
np.random.randint(0, 10, (3, 3))  # random integers in [0,10)
Out[23]:
array([[4, 2, 2],
       [7, 4, 3],
       [9, 6, 1]])
In [24]:
np.random.normal(0, 1, (3, 3))  
Out[24]:
array([[ 0.61342943, -0.70669823, -0.51996023],
       [ 0.29909795, -0.06506812,  1.0820678 ],
       [-1.06165841,  0.95420008, -0.09446331]])
  • Give (rows, columns) as a tuple for shape
  • A matrix (2-d array) is made from a list of equal-length lists: np.array([[1,2,3],[4,5,6]])
In [25]:
np.array([[1,2,3],[4,5,6]])
Out[25]:
array([[1, 2, 3],
       [4, 5, 6]])

Random Number Generators¶

The currently recommended way to draw random numbers is via a generator object:

In [12]:
rng = np.random.default_rng()
In [13]:
rng.choice(['apple', 'banana', 'cherry'], size=10)
Out[13]:
array(['banana', 'apple', 'apple', 'banana', 'banana', 'cherry', 'apple',
       'banana', 'banana', 'cherry'], dtype='<U6')

Randomly picks 10 items (with replacement by default).

In [35]:
index=rng.choice(np.arange(0, 10), size=10)
index
Out[35]:
array([5, 6, 1, 1, 6, 7, 1, 6, 7, 3])
In [39]:
my_array=np.arange(0,10)
rng.shuffle(my_array)
print(my_array)
[1 6 3 4 2 8 5 0 9 7]
In [40]:
X = rng.poisson(lam=2, size=20)
X = rng.poisson(lam=2, size=(10, 3))  # tuple size -> 2-d array
U = rng.random((4, 3))                # Uniform(0,1)
B = rng.binomial(1, 1/2, (4, 3))      # Bernoulli(1/2)
In [44]:
print(B)
[[0 0 1]
 [1 0 0]
 [1 1 0]
 [1 1 0]]

This mirrors rpois(), runif(), rbinom() in R, but the distribution's parameters are accessed as methods of the generator object.

In [45]:
# Pass any integer as a seed
rng = np.random.default_rng(seed=42)

print(rng.random()) # This will output the exact same number every single time
0.7739560485559633

Exercise¶

(1) Generate 30 random samples from Normal(0,1)
(2) Randomly assign 15 samples into control and treatment group

In [39]:
n=15
sam = rng.normal(loc=0, scale=1, size=2*n)   # rnorm(30)
sam[:5]
Out[39]:
array([-0.14338303, -0.9611649 , -0.57378864, -0.643139  , -2.91023702])
In [43]:
group=np.repeat([0,1], 15)
group
Out[43]:
array([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1,
       1, 1, 1, 1, 1, 1, 1, 1])
In [46]:
sgroup=rng.permutation(group)
sgroup
Out[46]:
array([0, 1, 1, 1, 1, 0, 0, 0, 0, 1, 1, 1, 0, 1, 0, 0, 0, 0, 0, 0, 1, 1,
       1, 0, 0, 1, 1, 1, 0, 1])
In [49]:
control=sam[sgroup==0]
trt=sam[sgroup==1]
control
Out[49]:
array([-0.14338303,  1.75494901,  0.63006027, -0.42047308,  0.76775837,
        1.26134475, -0.03232397, -0.96025364,  2.10660849,  1.56855716,
       -0.01562377, -0.87418331, -0.33996295, -0.18621914,  1.34673547])
In [50]:
trt
Out[50]:
array([-0.9611649 , -0.57378864, -0.643139  , -2.91023702,  0.42213754,
       -1.97139064, -0.54171989, -1.155092  ,  0.69490999,  1.53274065,
        1.10735997, -0.01341163,  0.0112715 , -0.24035469, -1.21511317])

Accessing Array Entries¶

In [46]:
seq = np.arange(-1, 1, 1/4)
print(seq)
print(seq[0])     # first entry: -1.0
print(seq[2:4])   # entries with index 2, 3
print(seq[3:])    # from index 3 to the end
print(seq[:4])    # from the start up to (not incl.) index 4
print(seq[::3])   # every third entry
print(seq[-1])    # last entry (negative index counts from the end)
[-1.   -0.75 -0.5  -0.25  0.    0.25  0.5   0.75]
-1.0
[-0.5  -0.25]
[-0.25  0.    0.25  0.5   0.75]
[-1.   -0.75 -0.5  -0.25]
[-1.   -0.25  0.5 ]
0.75

Key difference from R: In Python, a negative index counts entries from the end; it does not drop an entry as it does in R.

Indexing 2-D Arrays (Matrices)¶

In [48]:
M = np.array([[1, 2, 3], [4, 5, 6]])
print(M)
print(M[0, :])   # first row:    [1 2 3]
print(M[:, 1])   # second column: [2 5]
print(M[1, 2])   # single entry:  6
M[1, 2] = 7 # replace an entry
M
[[1 2 3]
 [4 5 6]]
[1 2 3]
[2 5]
6
Out[48]:
array([[1, 2, 3],
       [4, 5, 7]])

De-selecting rows/columns uses ~ rather than a negative index:

In [49]:
print(M[~0, :])   # drop the first row
even = M % 2 == 0
print(even)
M[~even]          # boolean mask: keep the odd entries
[4 5 7]
[[False  True False]
 [ True False False]]
Out[49]:
array([1, 3, 5, 7])

Outline¶

  1. Why Python? Getting oriented
  2. Basic Python objects: lists, tuples, dictionaries
  3. NumPy arrays: creating, indexing, slicing
  4. Array attributes, reshaping, and combining arrays
  5. Arithmetic and summary statistics on arrays
  6. Practice

Array Attributes¶

Attributes describe an array's dimensions and storage type; access with a . (no parentheses):

In [52]:
A = np.random.randint(10, size=(3, 4, 5))

A.ndim     # number of dimensions: 3
A.shape    # size of each dimension: (3, 4, 5)
A.size     # total number of entries: 60
A.dtype    # data type, e.g. dtype('int64')
Out[52]:
dtype('int64')

Every NumPy array has a single dtype: common ones are int64, float64, bool_.

Watch out! Assigning a float into an integer array silently truncates the value --- no warning is given!

In [56]:
A=np.random.randint(10, size=(3,4))
A
Out[56]:
array([[1, 7, 3, 2],
       [6, 9, 5, 8],
       [7, 3, 8, 6]])
In [58]:
A[0,0]=2.2
A
Out[58]:
array([[2, 7, 3, 2],
       [6, 9, 5, 8],
       [7, 3, 8, 6]])
In [59]:
A = A.astype(float)
A[0,0]=2.2
A
Out[59]:
array([[2.2, 7. , 3. , 2. ],
       [6. , 9. , 5. , 8. ],
       [7. , 3. , 8. , 6. ]])

Reshaping Arrays¶

In [60]:
np.reshape(np.linspace(0, 1, 21), (3, 7))
Out[60]:
array([[0.  , 0.05, 0.1 , 0.15, 0.2 , 0.25, 0.3 ],
       [0.35, 0.4 , 0.45, 0.5 , 0.55, 0.6 , 0.65],
       [0.7 , 0.75, 0.8 , 0.85, 0.9 , 0.95, 1.  ]])
In [61]:
np.reshape(np.linspace(0, 1, 21), (7, 3))
Out[61]:
array([[0.  , 0.05, 0.1 ],
       [0.15, 0.2 , 0.25],
       [0.3 , 0.35, 0.4 ],
       [0.45, 0.5 , 0.55],
       [0.6 , 0.65, 0.7 ],
       [0.75, 0.8 , 0.85],
       [0.9 , 0.95, 1.  ]])
  • reshape fills the new array across rows first
  • Use np.transpose(A) (or A.transpose()) to fill across columns instead
  • Arrays can have more than 2 dimensions, just as in R
In [62]:
np.transpose(np.reshape(np.linspace(0, 1, 21), (3, 7)))
Out[62]:
array([[0.  , 0.35, 0.7 ],
       [0.05, 0.4 , 0.75],
       [0.1 , 0.45, 0.8 ],
       [0.15, 0.5 , 0.85],
       [0.2 , 0.55, 0.9 ],
       [0.25, 0.6 , 0.95],
       [0.3 , 0.65, 1.  ]])

Methods: Applying Functions to Objects¶

Many NumPy functions can also be called as a method attached to the object with a .:

In [63]:
B = np.array([[1, 2], [3, 4]])
B
Out[63]:
array([[1, 2],
       [3, 4]])
In [64]:
np.transpose(B)   # function form
B.transpose()     # method form -- same result
Out[64]:
array([[1, 3],
       [2, 4]])
In [68]:
np.sum(B)         # function form
B.sum()           # method form -- same result
Out[68]:
np.int64(10)

This "object.method()" pattern has no direct analogue in base R and shows up constantly in Python.

Aliases vs. Copies¶

A subset of an array is an alias, not a new array --- editing it edits the original!

In [6]:
A = np.reshape(np.arange(24), (4, 6))
A
Out[6]:
array([[ 0,  1,  2,  3,  4,  5],
       [ 6,  7,  8,  9, 10, 11],
       [12, 13, 14, 15, 16, 17],
       [18, 19, 20, 21, 22, 23]])
In [7]:
A0 = A[:2, :2]
A0[1, 1] = -99
A
# A has ALSO changed!
Out[7]:
array([[  0,   1,   2,   3,   4,   5],
       [  6, -99,   8,   9,  10,  11],
       [ 12,  13,  14,  15,  16,  17],
       [ 18,  19,  20,  21,  22,  23]])

To get an independent copy, append .copy():

In [8]:
A0_copy = A[:2, :2].copy()
A0_copy[0, 0] = -99   # does NOT affect A
A
Out[8]:
array([[  0,   1,   2,   3,   4,   5],
       [  6, -99,   8,   9,  10,  11],
       [ 12,  13,  14,  15,  16,  17],
       [ 18,  19,  20,  21,  22,  23]])

Subsetting with a boolean mask¶

In [9]:
A[A < 5] = 0 # set to zero all entries in A which are less than 0.5
A
Out[9]:
array([[ 0,  0,  0,  0,  0,  5],
       [ 6,  0,  8,  9, 10, 11],
       [12, 13, 14, 15, 16, 17],
       [18, 19, 20, 21, 22, 23]])

Combining and Splitting Arrays¶

In [10]:
x = np.array([1, 2, 3])
y = np.array([98, 99, 100])
np.concatenate([x, y])     # [1 2 3 98 99 100]
Out[10]:
array([  1,   2,   3,  98,  99, 100])
In [14]:
u = rng.binomial(1,1/2,(4,3)) # 4 by 3
v = rng.random((2,3))         # 2 by 3
print(u)
print(v)
print(u[2:])
[[1 0 1]
 [1 1 1]
 [0 1 0]
 [1 0 0]]
[[0.60372623 0.37617457 0.70187553]
 [0.22796474 0.88346417 0.17529252]]
[[0 1 0]
 [1 0 0]]
In [18]:
np.vstack([u,v]) # stack rows (matching # cols)
Out[18]:
array([[1.        , 0.        , 1.        ],
       [1.        , 1.        , 1.        ],
       [0.        , 1.        , 0.        ],
       [1.        , 0.        , 0.        ],
       [0.60372623, 0.37617457, 0.70187553],
       [0.22796474, 0.88346417, 0.17529252]])
In [21]:
np.hstack([u[2:], v])    # stack columns (matching # rows)
Out[21]:
array([[0.        , 1.        , 0.        , 0.60372623, 0.37617457,
        0.70187553],
       [1.        , 0.        , 0.        , 0.22796474, 0.88346417,
        0.17529252]])

Splitting is the reverse operation:

In [42]:
x1, x2, x3 = np.split(np.arange(12), [3, 6])
print(x1)
print(x2)
print(x3)
[0 1 2]
[3 4 5]
[ 6  7  8  9 10 11]

Outline¶

  1. Why Python? Getting oriented
  2. Basic Python objects: lists, tuples, dictionaries
  3. NumPy arrays: creating, indexing, slicing
  4. Array attributes, reshaping, and combining arrays
  5. Arithmetic and summary statistics on arrays
  6. Practice

Arithmetic on NumPy Arrays¶

The operators + - * / and ** (exponent), % (modulus) work entrywise, as in R:

In [24]:
a = np.array([3, 4, 5])
c = 2
In [25]:
a + c    # [5 6 7]
Out[25]:
array([5, 6, 7])
In [26]:
a ** c   # [9 16 25]   ('^' is NOT exponentiation in Python!)
Out[26]:
array([ 9, 16, 25])
In [28]:
a // c   # floor divide: [1 2 2]
Out[28]:
array([1, 2, 2])

No recycling! Unlike R, NumPy will not silently recycle a shorter array to match a longer one --- mismatched shapes raise an error. Common math functions live in NumPy: np.exp, np.log, np.sin, np.arctan, ...

Summary Statistics on Arrays¶

In [30]:
D = rng.random((20, 3))
print(D)
np.sum(D)     # or D.sum()
np.min(D)     # or D.min()
np.max(D)     # or D.max()
[[0.62695242 0.90651526 0.07511508]
 [0.8588796  0.44915439 0.0346995 ]
 [0.20957312 0.12097211 0.89711882]
 [0.66346972 0.24289914 0.96323799]
 [0.98911702 0.65459397 0.91216912]
 [0.00739    0.31258323 0.11407253]
 [0.32975446 0.63630544 0.0810894 ]
 [0.15217003 0.93215753 0.88304247]
 [0.56933459 0.45617789 0.31308781]
 [0.29629497 0.79257319 0.27125197]
 [0.58167581 0.17638694 0.84098462]
 [0.75681068 0.08562706 0.42929889]
 [0.8166283  0.23701507 0.74515563]
 [0.09847735 0.70850438 0.39360128]
 [0.51253678 0.01925071 0.25216621]
 [0.17066232 0.40323255 0.63050813]
 [0.32971842 0.7170679  0.71863739]
 [0.44845096 0.1447243  0.30643352]
 [0.12308156 0.88863459 0.39452356]
 [0.68892699 0.20626377 0.06670593]]
Out[30]:
0.989117016557217
In [31]:
D.sum(axis=0)
Out[31]:
array([9.22990509, 9.09063942, 9.32289987])
In [47]:
D = rng.random((20, 3))

np.sum(D)     # or D.sum()
np.min(D)     # or D.min()
np.max(D)     # or D.max()

D.sum(axis=0)   # column sums
D.max(axis=1)   # row maxima
Out[47]:
array([0.61810602, 0.81397754, 0.5088196 , 0.94443434, 0.78419807,
       0.99668802, 0.79078276, 0.92715229, 0.8358856 , 0.69289495,
       0.8427604 , 0.84561569, 0.96577913, 0.75940195, 0.56301702,
       0.7367655 , 0.52794625, 0.55931858, 0.67722432, 0.88355547])
  • axis=0 operates down columns; axis=1 operates across rows
  • np.sum is much faster than Python's built-in sum on large arrays

Missing Values¶

Create a missing value with None; most summary functions have a nan-aware version:

In [32]:
size=D.shape
size
Out[32]:
(20, 3)
In [33]:
rng.binomial(1, 0.1, size=D.shape)
Out[33]:
array([[1, 0, 0],
       [0, 0, 0],
       [0, 0, 0],
       [0, 0, 0],
       [0, 0, 0],
       [0, 0, 0],
       [0, 0, 0],
       [1, 0, 0],
       [0, 0, 0],
       [0, 0, 0],
       [0, 0, 0],
       [0, 0, 0],
       [0, 0, 1],
       [0, 0, 0],
       [0, 1, 0],
       [0, 0, 0],
       [0, 0, 0],
       [0, 0, 0],
       [0, 0, 0],
       [0, 0, 0]])
In [34]:
D[rng.binomial(1, 0.1, size=D.shape) == 1] = None
print(D)
np.nanmax(D)          # ignores missing values
np.nanmin(D, axis=0)  # column minima, ignoring NaNs
[[0.62695242 0.90651526 0.07511508]
 [0.8588796  0.44915439 0.0346995 ]
 [0.20957312 0.12097211 0.89711882]
 [0.66346972 0.24289914 0.96323799]
 [0.98911702 0.65459397 0.91216912]
 [0.00739    0.31258323 0.11407253]
 [0.32975446 0.63630544 0.0810894 ]
 [0.15217003 0.93215753 0.88304247]
 [       nan 0.45617789 0.31308781]
 [0.29629497 0.79257319 0.27125197]
 [0.58167581 0.17638694 0.84098462]
 [0.75681068 0.08562706 0.42929889]
 [       nan 0.23701507 0.74515563]
 [0.09847735 0.70850438 0.39360128]
 [0.51253678 0.01925071 0.25216621]
 [0.17066232 0.40323255 0.63050813]
 [0.32971842 0.7170679  0.71863739]
 [0.44845096 0.1447243  0.30643352]
 [0.12308156 0.88863459 0.39452356]
 [0.68892699 0.20626377        nan]]
Out[34]:
array([0.00739   , 0.01925071, 0.0346995 ])

Compare: np.sum $\to$ np.nansum, np.mean $\to$ np.nanmean, etc. --- the same pattern as na.rm = TRUE in R, just with a different function name instead of an argument.

Outline¶

  1. Why Python? Getting oriented
  2. Basic Python objects: lists, tuples, dictionaries
  3. NumPy arrays: creating, indexing, slicing
  4. Array attributes, reshaping, and combining arrays
  5. Arithmetic and summary statistics on arrays
  6. Practice

Practice: Write Code¶

  1. Write code to create the list [0, 3, 6, 9, 12, 0, 3, 6, 9, 12].

  2. Create the NumPy array whose $i$th row (starting at $i=0$) is filled with the value $i$, repeated 4 times, for $i = 0, \dots, 7$.

  3. Write code to build the $(n-1) \times n$ "successive difference" matrix with $-1$ on the main diagonal and $1$ on the diagonal just above it, and zeros elsewhere, for any $n$.

  4. Simulate 10,000 rolls of a pair of six-sided dice with rng.integers(low=1, high=7, size=(10000,2)). Find the proportion of rolls whose sum is odd.

In [64]:
import numpy as np
x=list(np.arange(0,14, 3))*2
x
Out[64]:
[0, 3, 6, 9, 12, 0, 3, 6, 9, 12]
In [68]:
A=np.zeros((8,4), dtype=int)
for i in range(8): 
    A[i, :] = i
print(A)
[[0 0 0 0]
 [1 1 1 1]
 [2 2 2 2]
 [3 3 3 3]
 [4 4 4 4]
 [5 5 5 5]
 [6 6 6 6]
 [7 7 7 7]]
In [ ]:
A = np.array([[i] * 4 for i in range(8)])
In [69]:
A = np.arange(8).repeat(4).reshape(8, 4)
print(A)
[[0 0 0 0]
 [1 1 1 1]
 [2 2 2 2]
 [3 3 3 3]
 [4 4 4 4]
 [5 5 5 5]
 [6 6 6 6]
 [7 7 7 7]]
In [77]:
n=4
D=np.zeros((n-1, n))
D
Out[77]:
array([[0., 0., 0., 0.],
       [0., 0., 0., 0.],
       [0., 0., 0., 0.]])
In [79]:
np.fill_diagonal(D, -1) 
D
np.fill_diagonal(D[:, 1:], 1)
D
Out[79]:
array([[-1.,  1.,  0.,  0.],
       [ 0., -1.,  1.,  0.],
       [ 0.,  0., -1.,  1.]])
In [80]:
def diff_matrix(n):
    D = np.zeros((n - 1, n))
    np.fill_diagonal(D, -1)           # -1 on main diagonal
    np.fill_diagonal(D[:, 1:], 1)     # +1 on diagonal just above
    return D

print(diff_matrix(5))
[[-1.  1.  0.  0.  0.]
 [ 0. -1.  1.  0.  0.]
 [ 0.  0. -1.  1.  0.]
 [ 0.  0.  0. -1.  1.]]
In [82]:
rolls = rng.integers(low=1, high=7, size=(10000, 2))   # shape (10000, 2)
rolls.shape
                              # sum each pair
Out[82]:
(10000, 2)
In [86]:
totals = rolls.sum(axis=1)  
totals
prop_odd = np.mean(totals % 2 == 1)
print(f"Proportion of odd sums: {prop_odd:.4f}")
Proportion of odd sums: 0.5041

Practice: Read Code¶

Predict the output of each code chunk before running it.

In [56]:
# (1)
ch = "Why hello."
ch[2:]

# (2)
x = list(range(0, 15, 3))
x * 2

# (3)
a, b, c = [4, 5], ['cat', 'cow'], [True, False]
d = [a, b, c]
d[1][1]
Out[56]:
'cow'
In [36]:
ch = "Why hello."
ch[2:]
Out[36]:
'y hello.'
In [37]:
x = list(range(0, 15, 3))
x * 2
Out[37]:
[0, 3, 6, 9, 12, 0, 3, 6, 9, 12]
In [ ]: