Scientific Programming using Python

Python has excellent supprt for scientific computing. It offers a lot of packages for this purpose. There is also a significant amount of web support available for these packages. One of the most widely used packages is Scipy (pronounced "Sigh-Pie"). It has a number of core packages:

  1. Numpy (N-dimensional array package)
  2. Scipy Library (Fundamental library for scientific computing)
  3. Matplotlib (Comprehensive 2D Plotting)
  4. Ipython (Enhanced Interactive Console)
  5. Sympy (Symbolic mathematics)
  6. Pandas (Data structures & analysis)
  7. Nose (Framework for testing Python code)

Some of the packages built on Scipy:

  1. Chaco (Embedded, interactive plotting)
  2. Mayavi (3D visualization)
  3. Cython (build C/C++ libraries)
  4. Scikits (scikit-image for image processing, scikit-learn for machine learning)

If you are interested you should also take a look at the list of topical software available here.

In this lecture, we will cover the basics of Numpy, Scipy Library and Matplotlib. A list of some good tutorials on Scipy core/based packages are given below:

  1. Tentative Numpy Tutorial
  2. Numpy for Matlab users
  3. List of Numpy Examples
  4. A gallery of interesting Ipython notebooks
  5. Python notebook viewer
  6. Gallery of Matplotlib plots
  7. Gallery of scikit-learn image processing

Creating Numpy Arrays

We can create numpy arrays as follows:

In [84]:
import numpy as np

A = np.array([1,2,3,4,5]) 
#Creates a list and then converts it to an array

print 'A =',A
print 'type:',type(A)
print 'data type:', A.dtype
print 'dimensions:', A.ndim
print 'Shape',A.shape
A = [1 2 3 4 5]
type: <type 'numpy.ndarray'>
data type: int32
dimensions: 1
Shape (5,)

There are some functions to create arrays that are frequently required:

In [85]:
print "Zeros"
Z = np.zeros(2)
print 'Z =',Z
print 'type:',type(Z)
print 'data type:', Z.dtype
print 'dimensions:', Z.ndim
print 'Shape',Z.shape
print '\n'+'='*10+'\n'

print "More Zeros"
Z = np.zeros((1,2))
print 'Z =',Z
print 'type:',type(Z)
print 'data type:', Z.dtype
print 'dimensions:', Z.ndim
print 'Shape',Z.shape
print '\n'+'='*10+'\n'

print "Ones"
Z = np.ones((3,2))
print 'Z =',Z
print 'type:',type(Z)
print 'data type:', Z.dtype
print 'dimensions:', Z.ndim
print 'Shape',Z.shape
print '\n'+'='*10+'\n'

print "Identity"
Z = np.eye(3,3)
print 'Z =',Z
print 'type:',type(Z)
print 'data type:', Z.dtype
print 'dimensions:', Z.ndim
print 'Shape',Z.shape
print '\n'+'='*10+'\n'

print "Integer Range"
Z = np.arange(0,10,2)
print 'Z =',Z
print 'type:',type(Z)
print 'data type:', Z.dtype
print 'dimensions:', Z.ndim
print 'Shape',Z.shape
print '\n'+'='*10+'\n'

print "Linear Spaced"
Z = np.linspace(0,1,10)
print 'Z =',Z
print 'type:',type(Z)
print 'data type:', Z.dtype
print 'dimensions:', Z.ndim
print 'Shape',Z.shape
print '\n'+'='*10+'\n'

print "Random floating point numbers"
Z = np.random.random((3,3))
print 'Z =',Z
print 'type:',type(Z)
print 'data type:', Z.dtype
print 'dimensions:', Z.ndim
print 'Shape',Z.shape
print '\n'+'='*10+'\n'

print "Random Integers"
Z = np.random.randint(0,5,(3,3))
print 'Z =',Z
print 'type:',type(Z)
print 'data type:', Z.dtype
print 'dimensions:', Z.ndim
print 'Shape',Z.shape
print '\n'+'='*10+'\n'
Zeros
Z = [ 0.  0.]
type: <type 'numpy.ndarray'>
data type: float64
dimensions: 1
Shape (2,)

==========

More Zeros
Z = [[ 0.  0.]]
type: <type 'numpy.ndarray'>
data type: float64
dimensions: 2
Shape (1, 2)

==========

Ones
Z = [[ 1.  1.]
 [ 1.  1.]
 [ 1.  1.]]
type: <type 'numpy.ndarray'>
data type: float64
dimensions: 2
Shape (3, 2)

==========

Identity
Z = [[ 1.  0.  0.]
 [ 0.  1.  0.]
 [ 0.  0.  1.]]
type: <type 'numpy.ndarray'>
data type: float64
dimensions: 2
Shape (3, 3)

==========

Integer Range
Z = [0 2 4 6 8]
type: <type 'numpy.ndarray'>
data type: int32
dimensions: 1
Shape (5,)

==========

Linear Spaced
Z = [ 0.          0.11111111  0.22222222  0.33333333  0.44444444  0.55555556
  0.66666667  0.77777778  0.88888889  1.        ]
type: <type 'numpy.ndarray'>
data type: float64
dimensions: 1
Shape (10,)

==========

Random floating point numbers
Z = [[ 0.88581068  0.57842584  0.35561482]
 [ 0.42075489  0.66461818  0.57192114]
 [ 0.03384866  0.21617348  0.25488659]]
type: <type 'numpy.ndarray'>
data type: float64
dimensions: 2
Shape (3, 3)

==========

Random Integers
Z = [[3 0 1]
 [1 0 2]
 [4 0 4]]
type: <type 'numpy.ndarray'>
data type: int32
dimensions: 2
Shape (3, 3)

==========


Scalar Operations

Scalar operations work as expected!

In [86]:
A = np.zeros((3,4))#similarly 'ones'
A+=1 #Le Addition
print A
[[ 1.  1.  1.  1.]
 [ 1.  1.  1.  1.]
 [ 1.  1.  1.  1.]]

In [87]:
A = 2*A
print A
[[ 2.  2.  2.  2.]
 [ 2.  2.  2.  2.]
 [ 2.  2.  2.  2.]]

In [88]:
A = 1/A
print A
[[ 0.5  0.5  0.5  0.5]
 [ 0.5  0.5  0.5  0.5]
 [ 0.5  0.5  0.5  0.5]]

In [89]:
A = A/2
print A
[[ 0.25  0.25  0.25  0.25]
 [ 0.25  0.25  0.25  0.25]
 [ 0.25  0.25  0.25  0.25]]

In [90]:
print A**0.5
[[ 0.5  0.5  0.5  0.5]
 [ 0.5  0.5  0.5  0.5]
 [ 0.5  0.5  0.5  0.5]]

Array Operations

In [91]:
from numpy.random import random as rand
A = np.floor(10*rand((2,3)))
B = np.floor(10*rand(A.shape))
C = np.floor(10*rand(A.shape[::-1]))
D = np.array([[1,1],[2,3]])

print 'A:\n', A, '\n'
print 'B:\n', B, '\n'
print 'C:\n', C, '\n'
print 'D:\n', D, '\n'
print 'A+B:\n', A+B, '\n'
print 'A*B (Element wise):\n', A*B, '\n'
print 'Matrix product:\n', A.dot(C), '\n'
print 'Determinant (D):\n', np.linalg.det(D), '\n'
print 'Transpose (A):\n', A.T, '\n'
print 'Inverse (D):\n',np.linalg.inv(D), '\n'
print 'Pseudo Inverse (A):\n',np.linalg.pinv(A), '\n'
print 'Norm (D):\n',np.linalg.norm(D), '\n'
print 'Sum (A):\n',np.sum(A), '\n' #Similarly, mean, std, min, max, argmin, argmax
print 'Sum of rows (A):\n',np.sum(A,axis=0), '\n' 
print 'Sum of columns (A):\n',np.sum(A,axis=1), '\n'
print 'Number of elements in A greater than 5:\n',np.sum(A>5), '\n'
A:
[[ 0.  8.  6.]
 [ 9.  4.  9.]] 

B:
[[ 4.  8.  8.]
 [ 2.  9.  5.]] 

C:
[[ 5.  9.]
 [ 6.  4.]
 [ 9.  5.]] 

D:
[[1 1]
 [2 3]] 

A+B:
[[  4.  16.  14.]
 [ 11.  13.  14.]] 

A*B (Element wise):
[[  0.  64.  48.]
 [ 18.  36.  45.]] 

Matrix product:
[[ 102.   62.]
 [ 150.  142.]] 

Determinant (D):
1.0 

Transpose (A):
[[ 0.  9.]
 [ 8.  4.]
 [ 6.  9.]] 

Inverse (D):
[[ 3. -1.]
 [-2.  1.]] 

Pseudo Inverse (A):
[[-0.07439446  0.08650519]
 [ 0.10380623 -0.02768166]
 [ 0.02825836  0.03690888]] 

Norm (D):
3.87298334621 

Sum (A):
36.0 

Sum of rows (A):
[  9.  12.  15.] 

Sum of columns (A):
[ 14.  22.] 

Number of elements in A greater than 5:
4 


Indexing, Slicing and Iterating

Numpy indexing allows more flexibility in comparison to indexing in Python lists. For example:

In [92]:
x = np.linspace(0,1,10)
y = np.sin(2*np.pi*x)
print 'y:\n',y
print '\n'+'='*10+'\n'
print 'y[2]:\n',y[2]
print 'y[-2]:\n',y[-2]
print 'y[:2]:\n',y[:2]
print 'y[2:]:\n',y[2:]
print 'y[2:-2]:\n',y[2:-2]
print 'y[2:-2:2]:\n',y[2:-2:2]
print 'y[::-1]:\n',y[::-1]
print 'y[[1,3,4]]:\n',y[[1,3,4]] #This is not possible directly with Python lists
#You can also assign values
xnew=x.copy()
xnew[1:3]=[0,1]
print 'xnew:\n',xnew
y:
[  0.00000000e+00   6.42787610e-01   9.84807753e-01   8.66025404e-01
   3.42020143e-01  -3.42020143e-01  -8.66025404e-01  -9.84807753e-01
  -6.42787610e-01  -2.44929360e-16]

==========

y[2]:
0.984807753012
y[-2]:
-0.642787609687
y[:2]:
[ 0.          0.64278761]
y[2:]:
[  9.84807753e-01   8.66025404e-01   3.42020143e-01  -3.42020143e-01
  -8.66025404e-01  -9.84807753e-01  -6.42787610e-01  -2.44929360e-16]
y[2:-2]:
[ 0.98480775  0.8660254   0.34202014 -0.34202014 -0.8660254  -0.98480775]
y[2:-2:2]:
[ 0.98480775  0.34202014 -0.8660254 ]
y[::-1]:
[ -2.44929360e-16  -6.42787610e-01  -9.84807753e-01  -8.66025404e-01
  -3.42020143e-01   3.42020143e-01   8.66025404e-01   9.84807753e-01
   6.42787610e-01   0.00000000e+00]
y[[1,3,4]]:
[ 0.64278761  0.8660254   0.34202014]
xnew:
[ 0.          0.          1.          0.33333333  0.44444444  0.55555556
  0.66666667  0.77777778  0.88888889  1.        ]

Below are some examples of indexing with multidimensional arrays:

In [93]:
X=np.random.random((4,5))
print 'X:\n',X
print 'X[0]:\n',X[0]
print 'X[0,:]:\n',X[0,:]
print 'X[:,0]:\n',X[:,0]
print 'X[...,0]:\n',X[...,0] #ellipsis work the same way as the colon
print 'X[2,0]:\n',X[2,0]
print 'X[2][0]:\n',X[2][0]
print 'X[1:3,0]:\n',X[1:3,0]
X:
[[ 0.02355081  0.12316099  0.35530607  0.67601748  0.82298629]
 [ 0.9648274   0.1542122   0.53621761  0.16089639  0.30353625]
 [ 0.01580861  0.06799899  0.68276222  0.43109718  0.08827738]
 [ 0.89574545  0.46403566  0.13867801  0.51172339  0.25496873]]
X[0]:
[ 0.02355081  0.12316099  0.35530607  0.67601748  0.82298629]
X[0,:]:
[ 0.02355081  0.12316099  0.35530607  0.67601748  0.82298629]
X[:,0]:
[ 0.02355081  0.9648274   0.01580861  0.89574545]
X[...,0]:
[ 0.02355081  0.9648274   0.01580861  0.89574545]
X[2,0]:
0.0158086121226
X[2][0]:
0.0158086121226
X[1:3,0]:
[ 0.9648274   0.01580861]

This demonstrates only basic indexing operations. For more elaborate indexing schemes (such as boolean indexing and ix_(), please see the indexing section in the Numpy tutorial.

You can also iterate over an array as shown below:

In [94]:
for row in X:
    print X
[[ 0.02355081  0.12316099  0.35530607  0.67601748  0.82298629]
 [ 0.9648274   0.1542122   0.53621761  0.16089639  0.30353625]
 [ 0.01580861  0.06799899  0.68276222  0.43109718  0.08827738]
 [ 0.89574545  0.46403566  0.13867801  0.51172339  0.25496873]]
[[ 0.02355081  0.12316099  0.35530607  0.67601748  0.82298629]
 [ 0.9648274   0.1542122   0.53621761  0.16089639  0.30353625]
 [ 0.01580861  0.06799899  0.68276222  0.43109718  0.08827738]
 [ 0.89574545  0.46403566  0.13867801  0.51172339  0.25496873]]
[[ 0.02355081  0.12316099  0.35530607  0.67601748  0.82298629]
 [ 0.9648274   0.1542122   0.53621761  0.16089639  0.30353625]
 [ 0.01580861  0.06799899  0.68276222  0.43109718  0.08827738]
 [ 0.89574545  0.46403566  0.13867801  0.51172339  0.25496873]]
[[ 0.02355081  0.12316099  0.35530607  0.67601748  0.82298629]
 [ 0.9648274   0.1542122   0.53621761  0.16089639  0.30353625]
 [ 0.01580861  0.06799899  0.68276222  0.43109718  0.08827738]
 [ 0.89574545  0.46403566  0.13867801  0.51172339  0.25496873]]

In [95]:
for element in x:
    print element
0.0
0.111111111111
0.222222222222
0.333333333333
0.444444444444
0.555555555556
0.666666666667
0.777777777778
0.888888888889
1.0

Array Shape Manipulation

Numpy provides a lot of functions for manipulating the shape of an array. np.reshape is a very useful function in this regard. It returns a new array reshaped to your liking.

In [96]:
print 'x:\n',x
print 'x.rehsape(2,5):\n',x.reshape(2,5) #the reshaped array must have the same number of elements as original array
print 'x.rehsape(2,5):\n',x.reshape(-1,2) #Automatically sets the unspecified dimension
x:
[ 0.          0.11111111  0.22222222  0.33333333  0.44444444  0.55555556
  0.66666667  0.77777778  0.88888889  1.        ]
x.rehsape(2,5):
[[ 0.          0.11111111  0.22222222  0.33333333  0.44444444]
 [ 0.55555556  0.66666667  0.77777778  0.88888889  1.        ]]
x.rehsape(2,5):
[[ 0.          0.11111111]
 [ 0.22222222  0.33333333]
 [ 0.44444444  0.55555556]
 [ 0.66666667  0.77777778]
 [ 0.88888889  1.        ]]

xnew.resize(2,5) will resize the array in place.

In [97]:
print 'xnew:\n',xnew
xnew.resize(2,5) #resize doesn't allow negative index
print 'xnew resized:\n',xnew
xnew:
[ 0.          0.          1.          0.33333333  0.44444444  0.55555556
  0.66666667  0.77777778  0.88888889  1.        ]
xnew resized:
[[ 0.          0.          1.          0.33333333  0.44444444]
 [ 0.55555556  0.66666667  0.77777778  0.88888889  1.        ]]

ravel or flatten both flatten a multidimensional array

In [98]:
print 'X:\n',X
print 'X.ravel():\n',X.ravel()
print 'X.flatten():\n',X.flatten()
X:
[[ 0.02355081  0.12316099  0.35530607  0.67601748  0.82298629]
 [ 0.9648274   0.1542122   0.53621761  0.16089639  0.30353625]
 [ 0.01580861  0.06799899  0.68276222  0.43109718  0.08827738]
 [ 0.89574545  0.46403566  0.13867801  0.51172339  0.25496873]]
X.ravel():
[ 0.02355081  0.12316099  0.35530607  0.67601748  0.82298629  0.9648274
  0.1542122   0.53621761  0.16089639  0.30353625  0.01580861  0.06799899
  0.68276222  0.43109718  0.08827738  0.89574545  0.46403566  0.13867801
  0.51172339  0.25496873]
X.flatten():
[ 0.02355081  0.12316099  0.35530607  0.67601748  0.82298629  0.9648274
  0.1542122   0.53621761  0.16089639  0.30353625  0.01580861  0.06799899
  0.68276222  0.43109718  0.08827738  0.89574545  0.46403566  0.13867801
  0.51172339  0.25496873]

Some other useful functions include: vstack, hstack and np.newaxis which adds a dimension to an array.

In [99]:
print 'vstack:\n',np.vstack((x,x))
print 'hstack:\n',np.hstack((x,x))
print 'x[:,np.newaxis]:\n',x[:,np.newaxis]
print 'hstack on x[:,np.newaxis]:\n',np.hstack((x[:,np.newaxis],x[:,np.newaxis]))
vstack:
[[ 0.          0.11111111  0.22222222  0.33333333  0.44444444  0.55555556
   0.66666667  0.77777778  0.88888889  1.        ]
 [ 0.          0.11111111  0.22222222  0.33333333  0.44444444  0.55555556
   0.66666667  0.77777778  0.88888889  1.        ]]
hstack:
[ 0.          0.11111111  0.22222222  0.33333333  0.44444444  0.55555556
  0.66666667  0.77777778  0.88888889  1.          0.          0.11111111
  0.22222222  0.33333333  0.44444444  0.55555556  0.66666667  0.77777778
  0.88888889  1.        ]
x[:,np.newaxis]:
[[ 0.        ]
 [ 0.11111111]
 [ 0.22222222]
 [ 0.33333333]
 [ 0.44444444]
 [ 0.55555556]
 [ 0.66666667]
 [ 0.77777778]
 [ 0.88888889]
 [ 1.        ]]
hstack on x[:,np.newaxis]:
[[ 0.          0.        ]
 [ 0.11111111  0.11111111]
 [ 0.22222222  0.22222222]
 [ 0.33333333  0.33333333]
 [ 0.44444444  0.44444444]
 [ 0.55555556  0.55555556]
 [ 0.66666667  0.66666667]
 [ 0.77777778  0.77777778]
 [ 0.88888889  0.88888889]
 [ 1.          1.        ]]

Exercise: Using Matrices in Encryption (Hill Cipher)

Hill Cipher is based on simple matrix operations. Here is how it works:

  • A given input message (say s = 'target neutralized') is first coded numerically to give N(s) as: '116-97-114-103-101-116-32-110-101-117-116-114-97-108-105-122-101-100
  • A (typically large) n x n secret non-singular coding matrix is then used for encryption, for example, $C=\begin{pmatrix} 3 & 3 \\ 2 & 5 \end{pmatrix}$, as follows:

    • Rolling: Create vectors of n contiguous characters from N(s), e.g., $v_0 = \begin{pmatrix} 116 \\ 97 \end{pmatrix}$, $v_1 = \begin{pmatrix} 114 \\ 103 \end{pmatrix}$, .... If the length of the string not a multiple of n, then empty spaces are appended to the end of the string to make it so. Construct the matrix: $V=\begin{pmatrix}v_0 & v_1 & \ldots\end{pmatrix}$.

    • Compute the matrix: $E = (CV) \bmod 251$ to get the encrypted n-dimensional vectors. 251 is chosen because it is the biggest prime number less than 256.

    • Unrolling the numbers in $E$ and converting them back to string gives encrypted data.

  • Decryption is carried out by using: $d_i = (DE)(\bmod251)$ and then the text is obtained through reverse numerical coding. Here, $D=d \times adj(C)$ with $d$ being a scalar such that $d \times |C| \equiv 1 (\bmod 251)$ and $adj(C)$ is the adjoint of C. $d$ can be found by looping through 0 to 251 until you encounter a value for which $(d \times |C|)\bmod 251)$ equals $1$. If no such value exists then this means that 251 and the determinant of the chosen matrix have common factors and it is not possible to use this matrix for decoding.

Implement a function encryptHill(s,C) which returns the encrypted version of s using Hill Cipher with C as the code matrix. Also implement decryptHill(e,C) which takes a list of encrypted numbers (output of encryptHill and returns the decrypred string.

Sample Solution

In [100]:
import numpy as np
import random
import string

def numerify(s,n):
    return np.reshape([ord(c) for c in s],(n,-1))

def denumerify(lst):
    return ''.join([chr(int(round(i))) for i in lst])

def hillcrypt(s,C,nmax):
    n=C.shape[0]
    P=numerify(s,n)
    E=C.dot(P)%nmax
    etext=denumerify(E.flatten())
    return etext

def encryptHill(s,C,nmax):
    s+=' '*int(n*np.ceil(len(s)/float(n))-len(s))
    return hillcrypt(s,C,nmax)

def decryptHill(e,C,nmax):
    Ci=moduloInv(C,nmax)  
    return hillcrypt(e,Ci,nmax)

def moduloInv(C,nmax):      
    dC=np.linalg.det(C)    
    di=None
    for i in range(nmax):
        if (round(dC*i)%nmax)==1:
            di=i
            break 
    Ci=dC*np.linalg.inv(C)*di # remember: adj(C) = |C| x inv(C)
    return Ci%nmax
n=2
C=np.array([[3,3],[2,5]])
# for random C: np.random.randint(0,2,size=(n,n))
nmax=251

#random text
s= ''.join([random.choice(string.ascii_uppercase) for _ in range(500)]) 
# _ creates an unnamed variable. Useful when the index isn't going to be used.
etext = encryptHill(s,C,nmax)

dtext = decryptHill(etext,C,nmax)

print s,'\n'+'*'*10
print etext,'\n'+'*'*10
print dtext,'\n'+'*'*10
print 'Decryption Correct:',dtext.strip()==s.strip()
GAQOGJSADDUZLFQIIRUQSYLMDSOGDMABUGSLELAGGNIUQSFCOGJFXEHWACGSDLVYHFKBXOZOYEVIYRJVVNQHMHXODTHTXLCQVPQWHPECEKPNLWCGQMXVQHDVHVJVHMMWSDXGKRHYVFGXWWZNOTPYSSKXPPXGRATZIQXJSUYVFCZFVYFRUYJHSEEPUTPYARGICRWBNRWILLZOCSEWPKZMODNFKOUULPUYVZFIBRVBGKWNKRPXIPCNDFICTTPASBGAPQOGVVTPMNNLGNBZQSAMLPQYJHYAWYVDJTUDRZDMULQGTYQYGHNDYTENJBPJHKWLSOWOCFAHMYEXOPUBNUXAKZTEHBHLHMDYBHWLBXMJQWIXACGLDNCRJIIHFRCDMRKABEKHANHPCLMYOFFTTPIYEPWYRANLKBGNHLVRKFXTNYCMKWWFTYMJGNKCEEVZPESTIEXRMODVVJKQARAKMNHVQTHVXTXMGOGULKCEUCIXWUYDASRJYDCO 
**********
B���>pH�ER%�Z<?3�^B*!5>�	'VIX
f]cY��E,Y�= 
**********
GAQOGJSADDUZLFQIIRUQSYLMDSOGDMABUGSLELAGGNIUQSFCOGJFXEHWACGSDLVYHFKBXOZOYEVIYRJVVNQHMHXODTHTXLCQVPQWHPECEKPNLWCGQMXVQHDVHVJVHMMWSDXGKRHYVFGXWWZNOTPYSSKXPPXGRATZIQXJSUYVFCZFVYFRUYJHSEEPUTPYARGICRWBNRWILLZOCSEWPKZMODNFKOUULPUYVZFIBRVBGKWNKRPXIPCNDFICTTPASBGAPQOGVVTPMNNLGNBZQSAMLPQYJHYAWYVDJTUDRZDMULQGTYQYGHNDYTENJBPJHKWLSOWOCFAHMYEXOPUBNUXAKZTEHBHLHMDYBHWLBXMJQWIXACGLDNCRJIIHFRCDMRKABEKHANHPCLMYOFFTTPIYEPWYRANLKBGNHLVRKFXTNYCMKWWFTYMJGNKCEEVZPESTIEXRMODVVJKQARAKMNHVQTHVXTXMGOGULKCEUCIXWUYDASRJYDCO 
**********
Decryption Correct: True

Just for the fun of it, let's encrypt the code for the function 'encryptHill' using itself:

In [101]:
import inspect
ptext = inspect.getsource(encryptHill)
print 'Plain text:\n'+ptext
enc = encryptHill(ptext,C,nmax)
print '\nEncrypted text:\n'+enc
dec = decryptHill(enc,C,nmax)
print '\nDecrypted text:\n'+dec
Plain text:
def encryptHill(s,C,nmax):
    s+=' '*int(n*np.ceil(len(s)/float(n))-len(s))
    return hillcrypt(s,C,nmax)


Encrypted text:
������w����,������F���F��~���Ĺ�����n��Ͱ���X���q��R������3�����v�S�������C��/�`Wtz�%T�/!*����Dz�

Decrypted text:
def encryptHill(s,C,nmax):
    s+=' '*int(n*np.ceil(len(s)/float(n))-len(s))
    return hillcrypt(s,C,nmax)


Extra credit for Home Work:

  1. Try the above solution with C being a random binary matrix with n = 3. Most of the time you will not be able to decrypt the text. Do you know why? Can you fix it?
  2. Try the above solution with n = 100 for a random binary matrix. Most of the time it will not decrypt the message properly. Do you know why? Can you fix it?

  3. Hill Cipher is vulnerable to known plain text attacks, i.e., you can find out what the secret matrix was if you are given enough plain text and its encrypted form. Implement breakHill(s,e) which returns both the numerical coding and the encrypting matrix using the plain text s and its encrypted form e. For ease, you can initially assume that the numerical coding scheme is known. Here is an illustrative example.

(Basic) Plotting

Plotting is supported by matplotlib. It's real easy! Given two numpy arrays or python lists x and y, the plt.plot(x,y) function makes a plot of the x vs. y. What it essentially does is takes corresponding points in x and y and, by default, draws a line between them. You can refer to the matplotlib gallery for more elaborate graphing options.

In [102]:
import matplotlib.pyplot as plt
import numpy as np
t = np.linspace(0,1,100) 
y = np.sin(2*np.pi*2*t)
plt.plot(t,y) # t and y can be python lists or numpy arrays
plt.xlabel('t')
plt.ylabel('y')
plt.show()

You can also plot a histogram

In [103]:
plt.hist(np.random.random(1000)); plt.show()

Class Exercise: Determinants of random binary matrices

Let's see how the determinant of random binary matrices changes in response to their size. We will take a number of random matrices of a certain size n and find the average of the log of the magnitude of the determinant and then plot this average for different values of n.

In [104]:
import numpy as np
from numpy.linalg import det #for determinant
from numpy.random import randint as randi #for random binary matrices
import matplotlib.pyplot as plt

ntrials = range(100) # number of matrices for a single n
nrange = range(2,50,2) # array sizes (n)
dRand=[]

for n in nrange:
    d = [np.abs(det(randi(0,2,(n,n)))) for _ in ntrials]    
    dRand.append(np.mean(d)) # calc 
    
plt.plot(nrange,dRand,'ro-')
plt.xlabel('n')
plt.ylabel('magnitude of determinant')
plt.yscale('log')
plt.grid()
plt.show()

As you can see above, the magnitude of the determinant rises very quickly as the order of the matrix increases. This is why numpy, apart from a providing the det function, numpy also provides a slogdet funtion which returns a tuple containing the sign (1 for positve, -1 for negative and 0 for zero) and the natural log of the determinant of the input matrix.