#!/usr/bin/env python
# coding: utf-8

# # Einführung in NumPy
#  NumPy ist ein Paket das mathematische Funktionen, einen Zufallszahlengenerator, grundlegende Verfahren der linearen Algebra und vieles mehr zur Verfügung stellt.
# 

# In[1]:


import numpy as np


# In[2]:


np.array((1, 2)), np.array([1, 2])


# In[3]:


np.pi


# In[ ]:


np.sin(np.pi)


# Wir werden uns anschauen, wie man unter Nutzung von NumPy Vektoren und Matrizen definiert und einfache Operationen durchführt. 
# 
# ## Vektoren und Matrizen in NumPy erzeugen (Teil 1)
# 

# In[7]:


u = np.array([1, 2, 3])      # 1D Array (Vektor)


# In[8]:


v = np.array([4, 5])


# In[9]:


A = np.array([[3, 2, 1], [3, 5, 4], [8, 6, 7]])  # 2D Array (Matrix)


# In[10]:


type(u), type(v), type(A)


# In[11]:


u, v, A


# In[12]:


w = np.array(5)    # eine skalare Größe (0D array)


# In[13]:


w


# In[14]:


3*u  # jedes Element wird mit 3 multipliziert


# In[15]:


e = np.array([1, 1, 1])
e


# In[16]:


u - 3*e


# ## Attribute eines NumPy Arrays
# 
# __ndim__ Anzahl der Dimensionen eines NumPy arrays anzeigen: 
# 

# In[17]:


w.ndim


# In[18]:


A.ndim


# Es gibt __ndim__ auch als Methode (Funktion):

# In[20]:


np.ndim(u)


# In[21]:


np.ndim(A)


# Die Elemente des __shape__ Tupel geben die Größe der entsprechenden Array Dimension an.
# 
# Ebenso wie ndim gibt es auch shape sowohl als Attribut als auch als Methode.
# 

# In[22]:


A.shape


# In[23]:


u.shape


# In[24]:


w.shape


# In[25]:


np.shape(A)


# In[26]:


np.shape(u)


# __size__ gibt die Anzahl der Elemente des Arrays an:

# In[27]:


A.size


# In[28]:


np.size(A)


# In[29]:


u.size


# In[30]:


np.size(u)


# ## vstack und hstack
# Füge eine Zeile oder Spalte zur $3\times 3$ Matrix A hinzu

# In[31]:


# array vertikal zusammenkleben mit np.vstack
np.vstack((A, e))


# In[32]:


np.hstack((A, e)) # array horizontal zusammenkleben mit np.vstack


# In[33]:


e.reshape(3, 1)


# In[40]:


np.hstack((A, e.reshape(3, 1))) # array horizontal zusammenkleben mit np.vstack


# In[41]:


np.hstack((A, A))


# Alternativ kann man auch die (mächtigere) Funktion 
# __np.concatenate__ verwenden (siehe Hilfe).

# ## Zugriff auf Einträge  und ändern von Einträgen

# In[42]:


C = np.vstack((A, 2*A))
C


# In[43]:


C[0, 0] #Element links oben in C


# In[44]:


C[-1, -2]


# In[45]:


C[-1, -1] = 101 # Element rechts unten
C


# In[46]:


C[2, :]


# In[48]:


C[2, :] = -1
C


# In[50]:


C[1::2, [1]]


# In[51]:


C[1::2, 1]


# In[52]:


C[2:4, 1:3]


# ## Mathematische Operationen für NumPy Arrays
# ### Addition, Subtraktion, Multiplikation, Divions, modulo, potenzieren
# 
# *, / , % und &ast;&ast; wirken eintragsweise, wie  + und - 

# In[53]:


u


# In[57]:


a = np.array(range(4,7))
a


# In[58]:


u*a


# In[59]:


u**u


# In[60]:


A*A


# In[64]:


A


# In[61]:


A/A


# In[62]:


A.T  # transponierte Matrix


# In[63]:


A.T  # transponierte Matrix


# ### Der @ Operator 
#  @ ist als \_\___matmul__\_\_ implementiert und definiert auf np.arrays eine allgmeine Matrixmultplikation.
#  
#  Skalarprodukt von Vektoren

# In[65]:


u@u


# In[66]:


np.dot(u, u)


# In[67]:


A@u


# In[68]:


u@A


# In[69]:


A@A


# In[70]:


np.dot(A, A)


# ### reshape
#  Mit __.reshape__ oder __np.newaxis__ kann man aus einem _flachen_ Vektor eine Zeilen- oder Spaltenvektor machen
# 

# In[71]:


u_spalte = u.reshape(-1, 1) # Spaltenvektor
u_spalte


# In[72]:


u_spalte_ = u[:, np.newaxis]
u_spalte_


# In[73]:


u[0] = 100
u_spalte, u_spalte_  # u_spalte und u_s_ sind nur Ansichten von u und zeigen auf dieselben Einträge im Speicher


# In[74]:


a = a.reshape(1, -1)
a, a.ndim, a.shape


# In[75]:


u_zeile = u[np.newaxis, :]
u_zeile


# In[76]:


u_spalte@u_zeile, u_zeile@u_spalte  # Skalarprodukt und äußeres Produkt


# In[77]:


u_spalte@A


# In[78]:


u_zeile@A


# ### Weitere Arrayoperationen
# 
#  np.max(A) oder A.max() liefert das maximale Matrixelement:

# In[79]:


np.max(A), A.max()


# In[80]:


A.max(1) # Man kann auch das Maximum entlang der ersten Achse


# In[81]:


A.max(0) # Man kann auch das Maximum entlang der nullten Achse


# In[82]:


A


# In[83]:


np.max(A[0, :])  # oder das Maximum der 1. Zeile


# "flatten" A in ein 1-dim. Array umwandeln

# In[84]:


A.flatten()


# __np.argmin(A)__ / __np.argmax(A)__ liefert Index, bezüglich "flattened array", des kleinsten / größten Matrixelements
#  Falls das größte / kleinste Element mehrfach vorkommt, dann erhält man den ersten Index
# 

# In[86]:


A.argmax(), A.flatten()[np.argmax(A)]


# In[87]:


B = np.array([3*i-4 for i in range(9)]).reshape(3, 3)
B


# In[88]:


np.maximum(A, B) # elementweises maximum (analog np.minimum)


# ### Kronecker Produkt
#  Erzeugt ein zusammengesetztes Array aus Blöcken des 2. Arrays 
#  unter Verwendung der durch das 1. Array beschriebenen Skalierung
# 

# In[89]:


np.kron([1, 10, 100], [5, 6, 7])


# In[91]:


I2 = np.array([[1, 0], [0, 2]])
E2 = np.array([[1, 1], [2, 2]])
np.kron(I2, E2), np.kron(E2, I2)


# ## Definition spezieller Matrizen

# In[92]:


E = np.ones((3, 3))   # Matrix aus Einsen
Z = np.zeros((3, 3))  # Nullmatrix
I = np.eye(3)   # Identität
A = np.arange(16)


# In[93]:


E, Z, I, A


# In[94]:


np.eye(4, 6, k=1)  # erste obere Nebendiagonale


# In[95]:


np.eye(4, 6, k=-1)  # erste untere Nebendiagonale


# In[96]:


np.eye(4, 6)   # Default Wert ist k=0, Hauptdiagonale


# In[97]:


A = A.reshape(4, 4)
A


# In[98]:


np.diag(A)


# In[99]:


# Untere Dreiecksmatrix (lower triangular)
np.tril(A)


# In[100]:


# Obere Dreiecksmatrix (upper triangular)
np.triu(A)


# In[101]:


# np.diag kann auch Diagonalmatrizen erzeugen
B = np.diag(v.flatten())
B


# In[102]:


# np.diag für Nebendiagonalen
NU = np.diag(A, k=1)  # obere Nebendiagonale
NU


# In[103]:


NL = np.diag(A, k=-1) # untere Nebendiagonale
NL


# In[104]:


# Benutze np.diag zur Definition von Matrizen
x = [1, 2, 3]
A = np.diag(x, k=-1) + np.diag(x, k=1)
A


# In[ ]:




