Aside: [https://nyti.ms/2k7phfy](https://nyti.ms/2k7phfy)
Also: [https://nyti.ms/2kbI2yn](https://nyti.ms/2kbI2yn)

# Non-Proliferation and Safeguards

This unit covers discussions of nuclear non-proliferation and safeguards in the context of nuclear power and the nuclear fuel cycle.

```python
from IPython.display import IFrame
IFrame("https://embed.polleverywhere.com/free_text_polls/LaKxXWF9y4JKkue?short_poll=true", width="700", height="400")
```

```python
IFrame("https://embed.polleverywhere.com/multiple_choice_polls/YiHjKwlUd1Cit6s?short_poll=true", width="700", height="400")
```

```python
IFrame("https://embed.polleverywhere.com/free_text_polls/lBcbliuSuQGKea6?short_poll=true", width="700", height="400")
```

Source: [Huck Magazine Photography by Campaign for Nuclear Disarmament](https://www.huckmag.com/perspectives/activism-2/anti-nuclear-protest-art-will-stop-tracks/)

## Learning Objectives

At the end of this lesson, you will be equipped to:

- Recognize the position and history of movements against nuclear weapons.
- List key events in the history of nuclear weapons.
- Identify nuclear weapons states.
- Characterize the agreements between nuclear weapons states.
- Analyze the historical relationship between nuclear weapons and nuclear power.
- Name the national and international agency primarily responsible for enforcing non-proliferation treaties.
- Recognize methods used to enforce nuclear safeguards.
- Calculate MUF .
- Calculate $\sigma_{MUF}$.
- Calculate signficant quantities (SQ).
- Recognize the role of uncertainty in IAEA safeguards.
- Evaluate compliance with the three sigma limit ($MUF< 3\sigma_{MUF}$)
- Evaluate compliance with the cumulative MUF limit. ($MUF_c < 1 SQ$)
- Apply knowledge of the NPT to current and recent events.
- Discuss the failures of our nuclear weapons program (e.g. Bikini Atoll)

## Art to Consider

### Hiroshima survivor Hiroharu Kono

### Yusaku Kamekura

### A compendium of these

https://www.huckmag.com/perspectives/activism-2/anti-nuclear-protest-art-will-stop-tracks/

# How Did We Get Here?

## The Making of the Atomic Bomb

The best way to learn about this topic is to read Richard Rhodes excellent book, The Making of The Atomic Bomb.

However, let's summarize:

### 1930
German nationalists acheive shocking election victory.

### 1938
Theory of Nuclear Fission (Discovered on December 17,  1938 and explained theoretically in January 1939).

### 1939
 Szilard and Wigner draft the Einstein-Szilard letter.

### 1940 - 1946
Manhattan Project

The project to build the bomb was undertaken across many states in the US, with great secrecy.

At the University of Chicago, a group of scientists in the metallurgical laboratory (the Met Lab) were attempting to beat Heisenberg at attaining a controlled nuclear fission chain reaction.

### July, 16 1945

- Trinity Nuclear Test
- "The Gadget"/"Fat Man"
  - 6.19-kilogram (13.6 lb) Pu
  - Implosion type plutonium weapon
  - energy equivalent of 22 kilotons of TNT (92 TJ)

CC BY-SA 3.0, This is a derivative of a John Coster-Mullen drawing on page 140 of the 2003 printing of his book, "Atomic Bombs"

By Berlyn Brixner [Public domain], via Wikimedia Commons

### 8:15 a.m., August 6, 1945

- Hiroshima
- population 340,000 - 350,000
- Enola Gay, B-29
- "Little Boy"
  - contained 64 kg (141 lb) of enriched uranium.
  - less than 1 kg completely detonated.
  - energy equivalent of 15 kilotons of TNT (63 TJ).
  - gun type.
- 70,000 people dead instantly.
- 90,000–166,000 after the first year. About 70% of the city's buildings were destroyed, and another 7% severely damaged.

GFDL, John Coster-Mullen, via Wikipedia

### 11:02 a.m., August 9, 1945

- Nagasaki.
- population 263,000
- Bockscar, B-29
- "Fat Man"
  - contained 14 pounds (6.4 kg) Pu
  - energy equivalent of 21 kilotons of TNT (88 TJ; 1 g mass equivalent)
  - implosion type
- 35,000 people were killed instantly
- 39,000 - 80,000 by the end of 1945
- Fireball temp 3,900 °C (7,050 °F)
- Winds over 1,000 km/h (620 mph)

### 6:45 a.m., March 1, 1954

- Castle Bravo.
- dry fuel hydrogen bomb
- energy released: 15 megatons of TNT
- vastly (2.5x) exceeded expected  4 to 8 megatons (because of an unexpectedly high involvement of lithium-7 in the fusion reaction)
- shifting winds ---> greatest contamination every perpetrated by the U.S.
- Japanese fishing boat (fishermen died, contaminated fish were sold)

Public Domain, One possible version of the Teller–Ulam configuration

```python
IFrame("https://www.youtube.com/embed/C4LqqA4GdsY", width="700", height="400",frameborder="0")
```

### 11:32 a.m. October 30, 1961

- Tsar Bomba / Ivan / Vanya (in Russia, one of George Carlin's Words You Can't Say on Television)
- Soviet RDS-220, three-stage hydrogen bomb
  - 2nd/3rd stages had 238U "tamper" to reduce fast fissions & fallout
  - 97% of the total yield resulted from thermonuclear fusion
  - energy released: 50 megaton TNT (210 PJ)

By https://web.archive.org/web/20080518004931/http://www.atomicforum.org/russia/tsarbomba.html, Fair use, The Tsar Bomba fireball. From [http://www.atomicforum.org/russia/tsarbomba.html] {{Non-free historic image}} == Fair use for Tsar Bomba and History of the Teller–Ulam design

By Bourrichon - Own work based on numbers from the fr-Wikipedia article on a NASA map., CC BY-SA 3.0, Tsar Bomba total destruction radius, superimposed on Paris.

### October 16–28, 1962
Cuban Missile Crisis. The US, led by Kennedy, had strained relations with the Soviet Union, led by Khruschev. US Navy blocked missile shipments to Cuba.
Eventaully, the standoff resulted in
- Removal of the Soviet Union's nuclear missiles from Cuba
- Removal of American nuclear missiles from Turkey and Italy
- Agreement with the Soviet Union that the United States would never invade Cuba without direct provocation
- Creation of a nuclear hotline between the United States and the Soviet Union

### 1963
Partial test ban treaty. The Partial Test Ban Treaty (PTBT) is officially the 1963 Treaty Banning Nuclear Weapon Tests in the Atmosphere, in Outer Space and Under Water, which prohibited all test detonations of nuclear weapons except for those conducted underground.

## 1970 : NPT

Opened for signature in 1968, the treaty entered into force in 1970

The goals of the NPT were to:

- Prevent the spread of nuclear weapons
- Promote cooperation in peaceful uses of nuclear technology
- Acheive nuclear disarmament

Accordingly:

- Countries with weapons (UK, US, USSR, France, China) agree not to share.
- Countries without nuclear weapons promised not to develop them in return for nuclear power technology.
- Those that already have weapons agreed to disarm. (Article 6: Countries must "negotiate in good faith.")

NPT is reviewed annually.

### Membership

By File:NPT Participation.svg: Allstar86, L.tak, Danlaycock
derivative work: Danlaycock - This file was derived from:
 NPT Participation.svg, CC BY-SA 3.0, Link

#### Renegged
Only North Korea has backed out of their agreement.

#### Never signed

Indian, Pakistan, and Israel never joined. All three now have weapons.

### 1996

Comprehensive Test Ban Treaty

## Megatons to Megawatts (1993 - 2003)

- **20** year program
- **500 tons** of HEU (**90%** enriched)
- Soviet era warheads -> downblended to $<5\%$ enriched
- To be implemented on commercial terms without government funds.
  - United States named Enrichment Corporation (USEC) as the commercial agent
  - the Russian Federation designated Techsnabexport (TENEX) as its commercial agent.
- The first shipment of LEU took place in May 1995.

**Practice: Can You prove this is an accurate statement?**

Think back to enrichment. This is another feed factor problem.

\begin{align}
x_p &= 0.05\\
x_w &= 0.002\\
x_f &= 0.9\\
\end{align}

Thus, we can find the feed factor (kg feed per kg enriched):

\begin{align}
FF &= \frac{F}{P}\\
   &= \frac{x_p - x_w}{x_f - x_w}\\
   &= \frac{0.05 - 0.002}{0.9 - 0.002}\\
   & \\
   &= 0.0535
\end{align}

We also know the amount of feed, F, was 500 tons.

\begin{align}
FF = \frac{F}{P}\\
FF = 0.05\\
F = 5.0\times 10^5\\
\end{align}

```python
F = 500000 # kg
x_p = 0.05  # sold to US
x_w = 0.002 # depleted U
x_f = 0.9   # feed from  HEU

def ff(x_p, x_w, x_f):
    num = x_p - x_w
    denom = x_f - x_w
    return num/denom

print(ff(x_p, x_w, x_f),
      " kg of the HEU are needed to produce 1kg fuel.")
print("Therefore, ",1/ff(x_p, x_w, x_f),
      " kg of the new will be produced per kg of HEU")
print("Therefore, ",F/ff(x_p, x_w, x_f),
      " kg of the new low enriched uranium was produced")

```

    0.053452115812917596  kg of the HEU are needed to produce 1kg fuel.
    Therefore,  18.708333333333332  kg of the new will be produced per kg of HEU
    Therefore,  9354166.666666666  kg of the new low enriched uranium was produced

Now, based on our fuel utilization discussions, how many MWh is that?

\begin{align}
M = \frac{Q}{BU}\\
Q = \frac{P_0 \cdot CF \cdot T}{\eta_{th}}\\
\end{align}

We want to solve for $P_0\cdot CF \cdot T$, which will be in units of MWd (and we can convert to MWh.)
\begin{align}
\Rightarrow &M = \frac{P_0 \cdot CF \cdot T}{\eta_{th}BU}\\
\Rightarrow &M \eta_{th} BU = P_0 \cdot CF \cdot T\\
\Rightarrow &P_0 \cdot CF \cdot T [MWh] = M\eta_{th}BU[MWd]\times 24 \left[\frac{h}{d}\right]  \\
\end{align}

So, let's assume some typical values appropriate for 5% enriched fuel (this will give high-ish burnups).

\begin{align}
\eta_{th} = 0.33\mbox{ [MWe/MWth]}\\
BU = 50000\mbox{ [MWd/ton] } = 50 \mbox{ [MWd/kg] }\\
\end{align}

```python
M = F/ff(x_p, x_w, x_f)
eta = 0.33
h_per_d = 24
bu = 50

PCFT = M*eta*bu*h_per_d
print("And finally, the program produced ",'%e' % PCFT, " MWh.")
```

    And finally, the program produced  3.704250e+09  MWh.

### 2003 North Korea pulls out of NPT
The DPRK became the first country to pull out of the NPT under Article X.

Began a series of nuclear weapons tests in October 2006. They conducted a total of 6 tests between 2006 and 2017,
all of them being underground tests. One of the explosions was claimed to be a Hydrogrn bomb.

### 2011 New START Treaty goes into effect
Part of START Treaty series between the US and Russia that bagen in 1991. New START
initially was a 10 year agreement and was renewed for another 5 years in Jan/Feb 2021.
Treaty expires February 4, 2026

Limits the number of strategic weapons that each party can have
- 700 deployed ICBMs, deployed SLBM, and deployed heavy bombers
- 1550 warheads deployed on ICBMs, SLBM, and bombers (each bomber counts as 1 warhead)
- 800 deployed and non-deployed ICBM launchers, SLBM launchers, and heavy bombers

Allows each party to conduct inspections of the other parties stockpiles
- 18 on-site inspections a year
- Biannual data exchanges -- numbers and locations

### 2015 Iran and the Joint Comprehensive Plan of Action (JCPOA)
Multiple concerns over Iran developing a clandestine nuclear weapons program (and yes,
Iran has signed and ratified the NPT). A history of Iran's nuclear history has been
[written by NTI](https://www.nti.org/learn/countries/iran/nuclear/).
Large concern over their enrichment program.
Multiple sanctions were imposed on Iran by the US, the UN, and the EU.

Developed JCPOA between Iran, US, China, Russia, UK, France, and Germany
- Limits enrichment levels and types of centrifuges
- Limits stockpiles of LEU

#### 2018: The US pulled out of the agreement.
Since then Iran has increased their enrichment levels,
most recently [reaching 60% U-235](https://apnews.com/article/iran-uranium-enrichment-60-percent-ed89e322595004fddc65fd4e31c1131b).

#### 2020/2021:
- [Top Iran Nuclear Scientist killed](https://www.reuters.com/article/us-iran-nuclear-scientist/iranian-nuclear-scientist-killed-by-one-ton-automated-gun-in-israeli-hit-jewish-chronicle-idUSKBN2AA2RC)
- [Cyber Attach on Natanz](https://apnews.com/article/world-news-israel-iran-62a7aa3182992ed0f97b5486d71280c2)
- US is currently trying to re-enter the JCPOA

### 2019 Nenoksa Accident
Russian nuclear missile test site had an accident that killed 5 people. Russian
government tried to pass it off as a fire and explosion after a "test of a liquid
propellant jet engine". Later, officials at the lab came out that the scientists
were looking into creating small-scale energy sources using fissile materials,
similar to NASA's KiloPower project

### Sensitive Points in the Fuel Cycle
Based on what you know about the nuclear fuel cycle and nuclear weapons, what are parts of the
fuel cycle that are sensative to proliferation? What areas would we be more concerned about
proliferation occurring?

#  IAEA, Safeguards, MUF, SQ, Etc.

Please note, the remainder of this lecture is a compilation of material from the following two sources.

- [Nuclear 101: How IAEA Safeguards Work. Matthew Bunn.](https://www.youtube.com/watch?v=hQ-WSC-D-yw&index=1&list=PLp1QSxtgPnf49U1tLv15Mmq8sC-eyuBk9).
- [NSSPI at TAMU](http://nsspi.tamu.edu/)
- [Jake Vanderplas' AstroHackWeek intro to stats](https://github.com/AstroHackWeek/AstroHackWeek2014/blob/d172aae2736294d1fb61893f774cc6de0112ad7a/day2/ClassicalStatsPython.ipynb)

## IAEA
Facts about the IAEA.

- What is it?
- Where is it?
- Why is it?
- Who is it?
- Who leads it?
- Who works there?
- When did it start?

[https://www.iaea.org/sites/default/files/16/07/iaeaataglance0316.pdf](https://www.iaea.org/sites/default/files/16/07/iaeaataglance0316.pdf)
- Year Founded : 1957
- Member States 168
- Number of Employees ca. 2,500
- Laboratories 14
- Headquarters Vienna, Austria
- Liaison Offices
  - Geneva, Switzerland
  - New York, USA
- Regional Offices
  - Toronto, Canada
  - Tokyo, Japan
- Laboratories
  - Seibersdorf, Austria
  - Monaco
  - Triste, Italy
- Regular Budget : €359.3 million ($431.37 million)

> In 2015, 709 facilities and 577 locations outside facilities in 181 States were under IAEA safeguards, making them subject to verification by IAEA inspectors. IAEA inspectors performed 2118 inspections, spending a total of 13 248 calendar days in the field.

Being a safeguards inspector may be a job of interest for some of you. I encourage you to read through this: [https://www.iaea.org/newscenter/news/a-day-in-the-life-of-a-safeguards-inspector](https://www.iaea.org/newscenter/news/a-day-in-the-life-of-a-safeguards-inspector).

## Material Accountancy

```python
IFrame("http://nsspi-nssep.org/nssep/reference/technical-safeguards-terminology/purpose%2c-objective%2c-and-scope-of-iaea-safeguards.html", width=1000, height=1000)
```

### Significant Quantities
Direct use nuclear material

|  Material    |  SQ       |
|--------|---------|
| Pu    | 	8 kg  Pu |
| 233U	 | 8 kg  233U |
| HEU (235U $\ge$ 20%) | 	25 kg 235U |

Indirect use nuclear material

|  Material    |  SQ       |
|--------|---------|
| U(235U < 20%) |	75 kg 235U |
| Natural U | 10 t |
| Depleted U | 20t |
| Th	| 20 t  Th |

### Example: Significant Quantity

A small nuclear nation purchases, on the black market, 35 kg of 40% enriched uranium. How many significant quantities is that?

```python
0.4*35/25
```

    0.56

### Aside: Other nuclear materials

What materials might the IAEA also watch?

## Containment and Surveillance

So that the IAEA needs not re-inspect unaltered materials, a typical method is to contain and label that material with tamper-resistant labels or to apply surveillance to the facility.

#### Containment

### Surveillance

## Material Unaccounted For

The material unaccounted for is what we worry about. It may have been diverted by a **nefarious actor**.

This is often written simply as the "book inventory" minus the "ending physical inventory."

\begin{align}
MUF &= BI - PE\\
\end{align}

The book inventory is just the inventory in the beginning (or last time IAEA made an assessment) plus anything that was added and minus anything that was removed.

\begin{align}
BI &= (PB + X - Y)\\
MUF &= (PB + X - Y) - PE\\
PB &= \mbox{Beginning Physical inventory}\\
X &= \mbox{Increases to inventory}\\
Y &= \mbox{Removal from inventory}\\
PE &= \mbox{Ending Physical inventory}\\
\end{align}

### Stream Diversion

**ASIDE: Hanford example.**

While MUF at each individual inspection may not be a signficant quantity, the sum might be.

\begin{align}
MUF_c = \sum_{t=t_i}^{t=t_f} MUF_t
\end{align}

### Item v. Bulk Material Accounting

For item based accounting one counts the **number** of things (assemblies, containers with tags), so MUF should be an integer. If $MUF \ne 0$, either:

- there has been an accounting error
- there has been some diversion of items

For bulk accounting, the MUF is rarely exactly zero. No detector is perfect, but also the processing of fluids, powders, gases, and other bulk masses is inherently lossy. So, it is important in this case to quantify the uncertainty in the material balance.

\begin{align}
\sigma_{MUF} = \mbox{uncertainty of MUF}\\
\end{align}

### Constraints

The two following constraints are important red flags.
\begin{align}
MUF< 3\sigma_{MUF}\\
MUF_c < 1 SQ
\end{align}

## Measurement uncertainties

**No detector is perfect**

So, let's talk statistics.

### Sampling

#### Mean

The mean is easy:

\begin{align}
\bar{x} = \frac{1}{N}\sum_{n=1}^{N}x_n
\end{align}

#### Standard deviation:

First, we describe the experimental variance ($\sigma^2$). The standard deviation is the square root of the variance.

\begin{align}
\sigma = \sqrt{\frac{1}{N-1}\sum_{i-1}^{N}(x_i -\bar{x})^2}
\end{align}

The relative standard deviation, S, is normalized by the mean:

\begin{align}
S = \frac{\sigma}{\bar{x}} = \frac{\sqrt{\frac{1}{N-1}\sum_{i-1}^{N}(x_i -\bar{x})^2}}{\bar{x}}
\end{align}

#### Uncertainty

The measured value ($\bar{x}$) and the true value (T) differ because of uncertainty. First, there is random error, $\epsilon$. Second, there is systematic error, $\Delta$. These can be either negative or positive.

\begin{align}
\bar{x} = T + \epsilon + \Delta
\end{align}

To model this, we can relate these errors back to the standard deviation, such that:

\begin{align}
\sigma^2 = \sigma_r^2 + \sigma_s^2
\end{align}

### Example: MUF Uncertainty

You are an inspector responsible for Pu balance in a specific MBA (material balance area) at LaHague.

**On January 1,**
- You measure 57kg of separated Pu.
- You tag the containers with tamper-resistant seals.

**On February 1,**
- Two tagged containers (5kg total) are missing. Areva provides evidence they were sent to MOX fuel fabrication.
- One new untagged container has appeared.
- Their accounts say 100kg of spent fuel entered the facility in January and 1kg Pu was separated into the new container.
- 99kg of non-Pu spent fuel remainder is present.

**What is the book inventory for just Pu?**

```python

PB = 57
X = 1
Y = 5
BI = PB + X - Y

print(BI)
```

    53

To determine the MUF, you take 10 readings of the new canister and you get the following results for its contained Pu amount:

m = [1.30, 1.045, 1.012, 0.988, 0.998, 1.051, 1.087, 0.960, 0.91, 1.11]

```python
import math
m = [1.30, 1.045, 1.012, 0.988, 0.998, 1.051, 1.087, 0.960, 0.91, 1.11]

def mean(m):
    return sum(m)/len(m)

def var(m):
    dif = 0
    for i in m:
        dif += pow(i-mean(m), 2)
    sig_sq = dif/(len(m)-1)
    sig = math.sqrt(sig_sq)
    return sig

def lim_sig(var, muf):
    return (3*var < muf)

print("Mean of measurements: ", mean(m))
muf = abs(mean(m) - (BI - 52))
print("MUF : ", muf)
print('Has the 3sig limit been hit? ', lim_sig(var(m),muf))

```

    Mean of measurements:  1.0460999999999998
    MUF :  0.04609999999999981
    Has the 3sig limit been hit?  False

Let's say this kind of MUF value happens every month due to a diversion. **How many months until we hit the MUF_c limit?**

```python
pu_lim = 8 #kg
print("If MUF is due to diversion, the MUF_c limit will be struck in ", pu_lim/muf, "months")
```

    If MUF is due to diversion, the MUF_c limit will be struck in  173.53579175705062 months

### Number of measurements needed

Okay, so we stated before that we're sortof confident in our measurement. How many samples does one have to take to draw appropriate conclusions.

\begin{align}
n &= N\left( 1 - \beta^{x/M}\right)\\
n &= \mbox{number of samples per item}\\
N &= \mbox{number of items per facility}\\
\beta &= \mbox{non-detection probability}\\
x &= \mbox{average nuclear material weight}\\
M &= \mbox{mass detection goal}\\
\end{align}

```python
# Imagine a facility with 100 cans of Pu
N = 100 # PuOx cans
x = 1.0 # kg Pu per can

# How confident do we need to be?
beta = 0.2 #80% confidence
M = 8 #kg Pu, (1SQ)

def n(items, beta, x, M):
    return items*(1 - pow(beta, x/M))

n(N, beta, x, M)
```

    18.223456604205744

## Wrap-Up

At the end of this lesson, you should be equipped to:

- Recognize the position and history of movements against nuclear weapons.
- List key events in the history of nuclear weapons.
- Identify nuclear weapons states.
- Characterize the agreements between nuclear weapons states.
- Analyze the historical relationship between nuclear weapons and nuclear power.
- Name the national and international agency primarily responsible for enforcing non-proliferation treaties.
- Recognize methods used to enforce nuclear safeguards.
- Calculate MUF .
- Calculate $\sigma_{MUF}$.
- Calculate signficant quantities (SQ).
- Recognize the role of uncertainty in IAEA safeguards.
- Evaluate compliance with the three sigma limit ($MUF< 3\sigma_{MUF}$)
- Evaluate compliance with the cumulative MUF limit. ($MUF_c < 1 SQ$)
- Apply knowledge of the NPT to current and recent events.
- Discuss the failures of our nuclear weapons program (e.g. Bikini Atoll)

```python
from IPython.core.display import HTML
HTML("""<video controls src="{0}" loop=1 autoplay width=100%/>""".format('https://img.buzzfeed.com/buzzfeed-static/static/2014-07/3/11/enhanced/webdr04/anigif_enhanced-buzz-21275-1404402138-31.gif?output-format=mp4'))

```

<video controls src="https://img.buzzfeed.com/buzzfeed-static/static/2014-07/3/11/enhanced/webdr04/anigif_enhanced-buzz-21275-1404402138-31.gif?output-format=mp4" loop=1 autoplay width=100%/>


```python
from google.colab import drive
drive._mount('/content/drive')
```

    Mounted at /content/drive

```python
import os
os.chdir('./drive/MyDrive/Python/PyTorch')
```

```python
pwd
```

    '/content/drive/MyDrive/Python/PyTorch'

```python
pip install d2l
```

    Collecting d2l
      Downloading d2l-0.17.1-py3-none-any.whl (82 kB)
         |████████████████████████████████| 82 kB 613 kB/s
    Collecting numpy==1.18.5
      Downloading numpy-1.18.5-cp37-cp37m-manylinux1_x86_64.whl (20.1 MB)
         |████████████████████████████████| 20.1 MB 1.4 MB/s
    Collecting pandas==1.2.2
      Downloading pandas-1.2.2-cp37-cp37m-manylinux1_x86_64.whl (9.9 MB)
         |████████████████████████████████| 9.9 MB 56.1 MB/s
    Requirement already satisfied: jupyter==1.0.0 in /usr/local/lib/python3.7/dist-packages (from d2l) (1.0.0)
    Collecting matplotlib==3.3.3
      Downloading matplotlib-3.3.3-cp37-cp37m-manylinux1_x86_64.whl (11.6 MB)
         |████████████████████████████████| 11.6 MB 46.4 MB/s
    Collecting requests==2.25.1
      Downloading requests-2.25.1-py2.py3-none-any.whl (61 kB)
         |████████████████████████████████| 61 kB 8.8 MB/s
    Requirement already satisfied: ipykernel in /usr/local/lib/python3.7/dist-packages (from jupyter==1.0.0->d2l) (4.10.1)
    Requirement already satisfied: nbconvert in /usr/local/lib/python3.7/dist-packages (from jupyter==1.0.0->d2l) (5.6.1)
    Requirement already satisfied: jupyter-console in /usr/local/lib/python3.7/dist-packages (from jupyter==1.0.0->d2l) (5.2.0)
    Requirement already satisfied: notebook in /usr/local/lib/python3.7/dist-packages (from jupyter==1.0.0->d2l) (5.3.1)
    Requirement already satisfied: ipywidgets in /usr/local/lib/python3.7/dist-packages (from jupyter==1.0.0->d2l) (7.6.5)
    Requirement already satisfied: qtconsole in /usr/local/lib/python3.7/dist-packages (from jupyter==1.0.0->d2l) (5.2.2)
    Requirement already satisfied: pillow>=6.2.0 in /usr/local/lib/python3.7/dist-packages (from matplotlib==3.3.3->d2l) (7.1.2)
    Requirement already satisfied: python-dateutil>=2.1 in /usr/local/lib/python3.7/dist-packages (from matplotlib==3.3.3->d2l) (2.8.2)
    Requirement already satisfied: pyparsing!=2.0.4,!=2.1.2,!=2.1.6,>=2.0.3 in /usr/local/lib/python3.7/dist-packages (from matplotlib==3.3.3->d2l) (3.0.6)
    Requirement already satisfied: cycler>=0.10 in /usr/local/lib/python3.7/dist-packages (from matplotlib==3.3.3->d2l) (0.11.0)
    Requirement already satisfied: kiwisolver>=1.0.1 in /usr/local/lib/python3.7/dist-packages (from matplotlib==3.3.3->d2l) (1.3.2)
    Requirement already satisfied: pytz>=2017.3 in /usr/local/lib/python3.7/dist-packages (from pandas==1.2.2->d2l) (2018.9)
    Requirement already satisfied: idna<3,>=2.5 in /usr/local/lib/python3.7/dist-packages (from requests==2.25.1->d2l) (2.10)
    Requirement already satisfied: certifi>=2017.4.17 in /usr/local/lib/python3.7/dist-packages (from requests==2.25.1->d2l) (2021.10.8)
    Requirement already satisfied: chardet<5,>=3.0.2 in /usr/local/lib/python3.7/dist-packages (from requests==2.25.1->d2l) (3.0.4)
    Requirement already satisfied: urllib3<1.27,>=1.21.1 in /usr/local/lib/python3.7/dist-packages (from requests==2.25.1->d2l) (1.24.3)
    Requirement already satisfied: six>=1.5 in /usr/local/lib/python3.7/dist-packages (from python-dateutil>=2.1->matplotlib==3.3.3->d2l) (1.15.0)
    Requirement already satisfied: tornado>=4.0 in /usr/local/lib/python3.7/dist-packages (from ipykernel->jupyter==1.0.0->d2l) (5.1.1)
    Requirement already satisfied: jupyter-client in /usr/local/lib/python3.7/dist-packages (from ipykernel->jupyter==1.0.0->d2l) (5.3.5)
    Requirement already satisfied: ipython>=4.0.0 in /usr/local/lib/python3.7/dist-packages (from ipykernel->jupyter==1.0.0->d2l) (5.5.0)
    Requirement already satisfied: traitlets>=4.1.0 in /usr/local/lib/python3.7/dist-packages (from ipykernel->jupyter==1.0.0->d2l) (5.1.1)
    Requirement already satisfied: prompt-toolkit<2.0.0,>=1.0.4 in /usr/local/lib/python3.7/dist-packages (from ipython>=4.0.0->ipykernel->jupyter==1.0.0->d2l) (1.0.18)
    Requirement already satisfied: decorator in /usr/local/lib/python3.7/dist-packages (from ipython>=4.0.0->ipykernel->jupyter==1.0.0->d2l) (4.4.2)
    Requirement already satisfied: pygments in /usr/local/lib/python3.7/dist-packages (from ipython>=4.0.0->ipykernel->jupyter==1.0.0->d2l) (2.6.1)
    Requirement already satisfied: pexpect in /usr/local/lib/python3.7/dist-packages (from ipython>=4.0.0->ipykernel->jupyter==1.0.0->d2l) (4.8.0)
    Requirement already satisfied: pickleshare in /usr/local/lib/python3.7/dist-packages (from ipython>=4.0.0->ipykernel->jupyter==1.0.0->d2l) (0.7.5)
    Requirement already satisfied: simplegeneric>0.8 in /usr/local/lib/python3.7/dist-packages (from ipython>=4.0.0->ipykernel->jupyter==1.0.0->d2l) (0.8.1)
    Requirement already satisfied: setuptools>=18.5 in /usr/local/lib/python3.7/dist-packages (from ipython>=4.0.0->ipykernel->jupyter==1.0.0->d2l) (57.4.0)
    Requirement already satisfied: wcwidth in /usr/local/lib/python3.7/dist-packages (from prompt-toolkit<2.0.0,>=1.0.4->ipython>=4.0.0->ipykernel->jupyter==1.0.0->d2l) (0.2.5)
    Requirement already satisfied: widgetsnbextension~=3.5.0 in /usr/local/lib/python3.7/dist-packages (from ipywidgets->jupyter==1.0.0->d2l) (3.5.2)
    Requirement already satisfied: jupyterlab-widgets>=1.0.0 in /usr/local/lib/python3.7/dist-packages (from ipywidgets->jupyter==1.0.0->d2l) (1.0.2)
    Requirement already satisfied: nbformat>=4.2.0 in /usr/local/lib/python3.7/dist-packages (from ipywidgets->jupyter==1.0.0->d2l) (5.1.3)
    Requirement already satisfied: ipython-genutils~=0.2.0 in /usr/local/lib/python3.7/dist-packages (from ipywidgets->jupyter==1.0.0->d2l) (0.2.0)
    Requirement already satisfied: jsonschema!=2.5.0,>=2.4 in /usr/local/lib/python3.7/dist-packages (from nbformat>=4.2.0->ipywidgets->jupyter==1.0.0->d2l) (2.6.0)
    Requirement already satisfied: jupyter-core in /usr/local/lib/python3.7/dist-packages (from nbformat>=4.2.0->ipywidgets->jupyter==1.0.0->d2l) (4.9.1)
    Requirement already satisfied: Send2Trash in /usr/local/lib/python3.7/dist-packages (from notebook->jupyter==1.0.0->d2l) (1.8.0)
    Requirement already satisfied: jinja2 in /usr/local/lib/python3.7/dist-packages (from notebook->jupyter==1.0.0->d2l) (2.11.3)
    Requirement already satisfied: terminado>=0.8.1 in /usr/local/lib/python3.7/dist-packages (from notebook->jupyter==1.0.0->d2l) (0.12.1)
    Requirement already satisfied: pyzmq>=13 in /usr/local/lib/python3.7/dist-packages (from jupyter-client->ipykernel->jupyter==1.0.0->d2l) (22.3.0)
    Requirement already satisfied: ptyprocess in /usr/local/lib/python3.7/dist-packages (from terminado>=0.8.1->notebook->jupyter==1.0.0->d2l) (0.7.0)
    Requirement already satisfied: MarkupSafe>=0.23 in /usr/local/lib/python3.7/dist-packages (from jinja2->notebook->jupyter==1.0.0->d2l) (2.0.1)
    Requirement already satisfied: mistune<2,>=0.8.1 in /usr/local/lib/python3.7/dist-packages (from nbconvert->jupyter==1.0.0->d2l) (0.8.4)
    Requirement already satisfied: entrypoints>=0.2.2 in /usr/local/lib/python3.7/dist-packages (from nbconvert->jupyter==1.0.0->d2l) (0.3)
    Requirement already satisfied: pandocfilters>=1.4.1 in /usr/local/lib/python3.7/dist-packages (from nbconvert->jupyter==1.0.0->d2l) (1.5.0)
    Requirement already satisfied: testpath in /usr/local/lib/python3.7/dist-packages (from nbconvert->jupyter==1.0.0->d2l) (0.5.0)
    Requirement already satisfied: bleach in /usr/local/lib/python3.7/dist-packages (from nbconvert->jupyter==1.0.0->d2l) (4.1.0)
    Requirement already satisfied: defusedxml in /usr/local/lib/python3.7/dist-packages (from nbconvert->jupyter==1.0.0->d2l) (0.7.1)
    Requirement already satisfied: webencodings in /usr/local/lib/python3.7/dist-packages (from bleach->nbconvert->jupyter==1.0.0->d2l) (0.5.1)
    Requirement already satisfied: packaging in /usr/local/lib/python3.7/dist-packages (from bleach->nbconvert->jupyter==1.0.0->d2l) (21.3)
    Requirement already satisfied: qtpy in /usr/local/lib/python3.7/dist-packages (from qtconsole->jupyter==1.0.0->d2l) (2.0.0)
    Installing collected packages: numpy, requests, pandas, matplotlib, d2l
      Attempting uninstall: numpy
        Found existing installation: numpy 1.19.5
        Uninstalling numpy-1.19.5:
          Successfully uninstalled numpy-1.19.5
      Attempting uninstall: requests
        Found existing installation: requests 2.23.0
        Uninstalling requests-2.23.0:
          Successfully uninstalled requests-2.23.0
      Attempting uninstall: pandas
        Found existing installation: pandas 1.1.5
        Uninstalling pandas-1.1.5:
          Successfully uninstalled pandas-1.1.5
      Attempting uninstall: matplotlib
        Found existing installation: matplotlib 3.2.2
        Uninstalling matplotlib-3.2.2:
          Successfully uninstalled matplotlib-3.2.2
    ERROR: pip's dependency resolver does not currently take into account all the packages that are installed. This behaviour is the source of the following dependency conflicts.
    google-colab 1.0.0 requires pandas~=1.1.0; python_version >= "3.0", but you have pandas 1.2.2 which is incompatible.
    google-colab 1.0.0 requires requests~=2.23.0, but you have requests 2.25.1 which is incompatible.
    datascience 0.10.6 requires folium==0.2.1, but you have folium 0.8.3 which is incompatible.
    albumentations 0.1.12 requires imgaug<0.2.7,>=0.2.5, but you have imgaug 0.2.9 which is incompatible.
    Successfully installed d2l-0.17.1 matplotlib-3.3.3 numpy-1.18.5 pandas-1.2.2 requests-2.25.1

# Manipulación de datos
Es importante saber como almacenar y manipular datos. Hay dos aspectos relevantes que tenemos que hacer con ellos:
* Adquisición
* Procesamiento cuando se encuentren en la computadora

Para adquirir datos es necesario saber almacenarlos, es por eso que una matriz n-dimensional o también llamado tensor nos será de ayuda.

## Inicio
Para comenzar se debe importar la librería Torch:

```python
import torch
```

Un tensor representa un arreglo de valores númericos, siendo probablemente multidimensional. De acuerdo a su número de ejes se denomina de las siguientes formas:
- 1 eje: vector
- 2 ejes: Matrix
- k > 2 ejes: $k^{th}$ tensor de orden

PyTorch presenta ya funciones para la creación de nuevos tensores con ciertos valores.

Ejemplo:
Con arange(n) se puede crear un vector de valores uniformes, que empiece en 0 (lo incluye) y termine en n (no lo incluye).

Por defecto, el tamaño del intervalo es 1. Los nuevos tensores se almacenan en la memoria principal y se designan para el cálculo basado en la CPU.

```python
x = torch.arange(12,dtype = torch.float32)
x
```

    tensor([ 0.,  1.,  2.,  3.,  4.,  5.,  6.,  7.,  8.,  9., 10., 11.])

Podemos conocer la forma de un tensor, es decir, la longitud a través de cada eje, mediante la propiedad _shape_.

```python
x.shape
```

    torch.Size([12])

Si lo que queremos es conocer el número total de elementos de un tensor, o visto de otra forma, el producto de los elementos de la forma, cuando se trata de un vector, tiene un sólo elemento en la forma como se pudo observar, y en este caso, es igual a su tamaño (total de elementos del tensor):

```python
x.numel()
```

    12

Si deseamos cambiar la forma de un tensor sin modificar el número de elementos ni sus valores, podemos usar la función _reshape_.

El vector x con forma (12,) que se ha utilizado se puede transformar en una matriz con forma (3,4). Este nuevo vector tendría los mismos valores pero organizados en 3 filas y 4 columnas. La forma cambió, no obstante, los elementos no:

```python
X = x.reshape(3,4)
X
```

    tensor([[ 0.,  1.,  2.,  3.],
            [ 4.,  5.,  6.,  7.],
            [ 8.,  9., 10., 11.]])

Los tensores pueden calcular de forma automática una dimensión a partir de las demás. En el ejemplo anterior, si la matriz objetivo tenía forma (altura, ancho), y sabiendo cualquiera de esos valores, ya sea, la altura o el ancho, la no conocida se obtiene implícitamente.

Para que un tensor realice esto, se remplaza un -1 en la dimensión que queremos que se infiera automáticamente.

Para lo que se ha realizado con el vector x, se pudo haber hecho de dos formas:

* x.reshape(3,4) lo cambiamos por -> x.reshape(-1,4)
* x.reshape(3,4) lo cambiamos por -> x.reshape(3,-1)

**Tensores inicializados con cero**

Para obtener tensores inicializadas con ceros, se realiza mediante la propiedad _zeros_. El siguiente ejemplo es un tensor con forma (2,3,4):

```python
torch.zeros((2,3,4))
```

    tensor([[[0., 0., 0., 0.],
             [0., 0., 0., 0.],
             [0., 0., 0., 0.]],

            [[0., 0., 0., 0.],
             [0., 0., 0., 0.],
             [0., 0., 0., 0.]]])

**Tensores inicializados con unos**

Para obtener tensores inicializadas con unos, se realiza mediante la propiedad _ones_.

```python
torch.ones((2,3,4))
```

    tensor([[[1., 1., 1., 1.],
             [1., 1., 1., 1.],
             [1., 1., 1., 1.]],

            [[1., 1., 1., 1.],
             [1., 1., 1., 1.],
             [1., 1., 1., 1.]]])

**Tensores inicializados con valores aleatorios**

Un ejemplo de uso de estos tensores es cuando se crean matrices que funcionan como parámetros de una red neuronal, ya que, normalmente sus valores se inicializan con valores aleatorios.

El siguiente tensor tiene una forma (5,4) y cada elemento se extrajo aleatoriamente de una distribución gausiana estándar (normal) con una media de 0 y desviación estándar de 1. Para esto, se utiliza la propiedad _randn_.

```python
torch.randn(5,4)
```

    tensor([[ 0.2204, -0.8333,  0.1837,  1.2486],
            [-0.5009,  0.1174,  0.7050,  0.7321],
            [-1.4421, -1.2758, -1.2015,  0.7555],
            [-1.6072,  0.7757, -0.0648,  0.8688],
            [-1.4232, -1.9594, -1.8735, -0.6077]])

**Tensores con valores específicos**

Se pueden especificar valores exactos a cada elemento del tensor dando una lista de Python o bien una lista de listas, que contengan valores numéricos.

```python
torch.tensor([[2,1,4,3],[1,2,3,4],[4,3,2,1]])
```

    tensor([[2, 1, 4, 3],
            [1, 2, 3, 4],
            [4, 3, 2, 1]])

## Operaciones
Las operaciones elementales aplican una operación escalar estándar a cada elemento de un array.

El operador escalar unitario (toma una entrada) se denota como $f : \mathbb R \to \mathbb R$, esto nos dice que la función mapea desde un número real a otro.

El operador escalar binario (toma dos entradas reales, y da una entrada) y se denota como $f : \mathbb R, \mathbb R \to \mathbb R$.

Ahora bien, si tenemos dos vectores **u** y **v** de la misma forma, y se tiene un operador binario, podemos producir un vector **c** = F(**u**,**v**), estableciendo $c_i ← f(u_i,v_i)$ para toda $i$, donde $c_i, u_i$ y $v_i$ son los i-ésimos elementos de los vectores.

Los operadores aritméticos estándar (suma, resta, multiplicación, división y exponenciación) son operaciones elementales para cualquier tensor de forma idéntica y arbitraria.

### Operaciones

```python
x = torch.tensor([1.0,2,4,8])
y = torch.tensor([2,2,2,2])
print(x+y) # suma
print(x-y) # resta
print(x*y) # multiplicación
print(x/y) # división
print(x**y) # exponenciación
```

    tensor([ 3.,  4.,  6., 10.])
    tensor([-1.,  0.,  2.,  6.])
    tensor([ 2.,  4.,  8., 16.])
    tensor([0.5000, 1.0000, 2.0000, 4.0000])
    tensor([ 1.,  4., 16., 64.])

Otra operación es la de exponenciación de la forma $e^x$:

```python
torch.exp(x)
```

    tensor([2.7183e+00, 7.3891e+00, 5.4598e+01, 2.9810e+03])

Además de operaciones elementales, se pueden realizar operaciones de álgebra lineal, como productos punto y multiplicación de matrices.

**Concatenar**

Los tensores se pueden concatenar, apilándolos de extremo a extremo para formar un tensor más grande. Se debe proporcionar una lista de tensores y establecer a lo largo de qué eje se quiere concatenar.

Ejemplo: Concatenar dos matrices de forma (3,4), si se realiza a lo largo de las filas, la matriz resultante tiene una forma de (6,4) que es la suma de las filas de ambas matrices (3+3). Esto se realiza de la siguiente forma:

```python
X = torch.arange(12, dtype=torch.float32).reshape((3,4))
Y = torch.tensor([[2.0, 1, 4, 3], [1, 2, 3, 4], [4, 3, 2, 1]])
torch.cat((X, Y), dim=0) # concatenar a lo largo de las filas

```

    tensor([[ 0.,  1.,  2.,  3.],
            [ 4.,  5.,  6.,  7.],
            [ 8.,  9., 10., 11.],
            [ 2.,  1.,  4.,  3.],
            [ 1.,  2.,  3.,  4.],
            [ 4.,  3.,  2.,  1.]])

Se observa que se coloca dim = 0, que representa el primer elemento de la forma que son las filas.

Ahora, para hacer la concatenación a lo largo de las columnas, con nuestro ejemplo nos daría una matriz de forma (3,8), que es la suma de las columnas de ambas matrices (4+4), y se coloca el parámetro dim = 1, haciendo referencia al segundo elemento de la forma que son las columnas:

```python
torch.cat((X, Y), dim=1) # concatenar a lo largo de las columnas
```

    tensor([[ 0.,  1.,  2.,  3.,  2.,  1.,  4.,  3.],
            [ 4.,  5.,  6.,  7.,  1.,  2.,  3.,  4.],
            [ 8.,  9., 10., 11.,  4.,  3.,  2.,  1.]])

**Tensor binario mediante sentencias lógicas**

Si tomamos a X y Y utilizados previamente, y realizamos X == Y para cada posición y resulta que son iguales en ésta, el tensor tendrá un valor de 1, o bien, True, en caso contrario tomara el valor de 0, o bien False.

```python
X == Y
```

    tensor([[False,  True, False,  True],
            [False, False, False, False],
            [False, False, False, False]])

**Suma de todos los elemento del tensor**

La suma de todos los elementos da como resultado un tensor de un solo elemento:

```python
X.sum()
```

    tensor(66.)

## Mecanismos de broadcasting
Bajo ciertas condiciones, incluso cuando las formas difieren, se pueden realizar operaciones elementales llamando un mecanismo de broadcasting.

Primero se amplían una o ambas matrices copiando los elementos de forma correcta, tal que tras esta transformación los dos tensores tengan la misma forma. Posterior, se realizan las operaciones elementales.

En la mayoría de los casos se "difune" a lo largo de un eje en donde un arreglo tiene inicialmente longitud de 1, esto se muestra:

```python
a = torch.arange(3).reshape(3,1)
b = torch.arange(2).reshape(1,2)
a,b
```

    (tensor([[0],
             [1],
             [2]]), tensor([[0, 1]]))

La forma de a es (3,1) y la forma de b es (1,2), de modo que no coinciden si queremos sumarlas.

Para poder sumarlas, se difunden las entradas de ambas matrices en una de forma (3,2). Para a, se replican las columnas, resultado en $a = [[0,0],[1,1],[2,2]]$ y para b, se replican las filas, resultado en $b = [[0,1],[0,1],[0,1]]$. De esta forma, es sencillo obsevar entonces que la suma de a con b, da lo siguiente:

```python
a+b
```

    tensor([[0, 1],
            [1, 2],
            [2, 3]])

## Indexación y slicing
Así como en un arreglo en Python, se puede acceder a los elementos de un tensor por índice, teniendo el primer elemento un índice 0. Así como en las listas, el último elemento se puede acceder a él mediante el -1, y así para las demás posiciones.
[1:3] selecciona el segundo y tercer elemento, el elemento de la posición 4 no se incluye.

```python
X[-1], X[1:3]
```

    (tensor([ 8.,  9., 10., 11.]), tensor([[ 4.,  5.,  6.,  7.],
             [ 8.,  9., 10., 11.]]))

Se puede escribir elementos en una matriz mediante los índices:

```python
X[1,2] = 9
X
```

    tensor([[ 0.,  1.,  2.,  3.],
            [ 4.,  5.,  9.,  7.],
            [ 8.,  9., 10., 11.]])

Si queremos asignar a varios elementos el mismo valor, se indexan los que se desean, tal como en un array.
Por ejemplo: [0:2,:] accede a la primera y segunda fila, y con : nos indica que toma todas las columnas.

```python
X[0:2, :] = 12
X
```

    tensor([[12., 12., 12., 12.],
            [12., 12., 12., 12.],
            [ 8.,  9., 10., 11.]])

## Guardar la memoria
Cuando ejecutamos operaciones puede ocurrir que se asigne nueva memoria a los resultados, aún cuando se asignen a la propia variable.

Por ejemplo, si se realiza la operaciones Y = X+Y, lo que ocurre es que ya no se referencía el tensor al que apuntaba Y, sino que ahora se apunta Y a la memoria recién asignada. Esto se observa a continuación, con la función __id()__ de Python, nos da la dirección exacta del objeto referenciado en memoria.
Si se ejecuta Y = Y+X, nos arrojará que id(Y) ahora apunta a una dirección diferente,  debido a que primero se evalúa Y+X, asignando nueva memoria para el resultado y luego hace que Y apunte a esta nueva dirección en memoria.
Es por esto que el siguiente código da False.

```python
before = id(Y)
Y = Y + X
id(Y) == before
```

    False

Esto puede ser indeseable por dos razones. Primero, porque no se quiere estar asignando memoria innecesaria cada vez, sino que es deseable que se realicen las actualizaciones en el mismo lugar. Segundo, porque al tener cientos de megabytes de parámetros en ciertos proyectos, podríamos apuntar a los mismos parámetros desde múltiples variables.

No obstante, realizar operaciones en el mismo lugar es sencillo. Podemos asignar el resultado de una operación a una matriz previamente asignada con la notación slice, por ejemplo: Y[:] = <expresión>.
Para realizar esto, primero creamos una matriz nueva Z con la misma forma que Y, utilizando la función __zeros_like__ para asignar un bloque de 0 entradas.

```python
Z = torch.zeros_like(Y)
print(Z)
print('id(Z):', id(Z))
Z[:] = X + Y
print('id(Z):', id(Z))
```

    tensor([[0., 0., 0., 0.],
            [0., 0., 0., 0.],
            [0., 0., 0., 0.]])
    id(Z): 140154347304144
    id(Z): 140154347304144

Ahora bien, si el valor de X no se utiliza más adelante, tambien se puede utilizar lo siguiente; X[:] = X+Y, o, X+=Y para reducir la sobrecarga de memoria de la operación. En el código siguiente se observa que funciona correctamente.

```python
before = id(X)
X += Y
id(X) == before
```

    True

## Conversión a otros objetos de Python
**Convertir a un tensor NumPy**

El tensor de torch y el array de Numpy compartirán ubicaciones de memoria subyacentes, y el cambio de uno a través de una operación in-place también cambiará el otro.

```python
A = X.numpy()
B = torch.from_numpy(A)
type(A), type(B)
```

    (numpy.ndarray, torch.Tensor)

Para convertir un tensor de tamaño 1 en un escalar de python, podemos usar la función __item__ o funciones incorporadas en Python como float o int.

```python
a = torch.tensor([3.5])
a,a.item(),float(a),int(a)
```

    (tensor([3.5000]), 3.5, 3.5, 3)

# Preprocesamiento de datos
El paqueta pandas es una de las herramientas populares de análisis de datos en Python. Pandas puede trabajar con tensores.
Para aplicar el aprendizaje profundo a la resolución de problemas, se comienza con un preprocesamiento de datos en bruto, en vez de aquellos datos bien preparados en el formato de tensor. Por esto, con pandas se hace el preprocesamiento para de ahí convertirlos en el formato tensor.

## Lectura del conjunto de datos
Se comenzará creando un conjunto de datos artificial que se almacena en un archivo csv: ./data/house_tiny.csv.

Se escribira el conjunto de datos fila por fila en un archivo csv:

```python
import os

os.makedirs(os.path.join('..', 'data'), exist_ok=True)
data_file = os.path.join('..', 'data', 'house_tiny.csv')
with open(data_file, 'w') as f:
  f.write('NumRooms,Alley,Price\n')  # Nombre de las columnas
  f.write('NA,Pave,127500\n')  # Cada fila representa un ejemplo de datos
  f.write('2,NA,106000\n')
  f.write('4,NA,178100\n')
  f.write('NA,NA,140000\n')
```

Para cargar el conjunto de datos en bruto desde el archivo csv que se creó, se importa el paquete pandas y se utiliza su función _read_csv_.

El conjunto de datos tiene 4 filas y 3 columnas, en donde cada fila describe el número de habitaciones ("NumRooms"), el tipo de callejón ("Alley") y el precio ("Price") de una casa, tal como se observó en el código anterior.

```python
!pip install pandas # instalación de pandas
```

    Requirement already satisfied: pandas in /usr/local/lib/python3.7/dist-packages (1.1.5)
    Requirement already satisfied: numpy>=1.15.4 in /usr/local/lib/python3.7/dist-packages (from pandas) (1.19.5)
    Requirement already satisfied: pytz>=2017.2 in /usr/local/lib/python3.7/dist-packages (from pandas) (2018.9)
    Requirement already satisfied: python-dateutil>=2.7.3 in /usr/local/lib/python3.7/dist-packages (from pandas) (2.8.2)
    Requirement already satisfied: six>=1.5 in /usr/local/lib/python3.7/dist-packages (from python-dateutil>=2.7.3->pandas) (1.15.0)

```python
import pandas as pd
data = pd.read_csv(data_file)

print(data)
```

       NumRooms Alley   Price
    0       NaN  Pave  127500
    1       2.0   NaN  106000
    2       4.0   NaN  178100
    3       NaN   NaN  140000

## Tratamiento de datos faltantes
Los valores NaN que se observan, son valores perdidos. Para tratar a estos datos, los métodos típicos son la imputación y la eliminación, donde la imputación sustituye los valores que faltan por otros sustituidos, y la eliminación ignora los valores que faltan.

Considerando la imputación, tenemos:
Mediante la indexación basada en la localización de enteros (iloc), dividimos los datos en entradas y salidas, siendo las entradas las dos primeras columnas y la salida la última columna. Para los valores que faltan, se sustituye en NaN por el valor medio de su columna correspondiente. Esto se realiza de la siguiente forma:

```python
inputs,outputs = data.iloc[:,0:2], data.iloc[:,2]
inputs = inputs.fillna(inputs.mean()) # sustituye NaN por valor medio de la columna
print(inputs)
```

       NumRooms Alley
    0       3.0  Pave
    1       2.0   NaN
    2       4.0   NaN
    3       3.0   NaN

Para los valores categóricos o discretos en las entradas, consideraremos NaN como una categoría. De este modo, Alley está contituido de Pave y NaN, con esto, pandas puede convertir automáticamente esta columna en dos columnas, una con el nombre "Alley_Pave" y la otra con "Alley_nan". Una fila cuya tipo de callejón sea "Pave" establecerá los valores de "Alley_Pave" y "Alley_nan" a 1, y 0. Una fila con tipo de callejon ausente, establecerá los valores 0 y 1. Esto se puede apreciar en el siguiente código:

```python
inputs = pd.get_dummies(inputs,dummy_na = True)
print(inputs)
```

       NumRooms  Alley_Pave  Alley_nan
    0       3.0           1          0
    1       2.0           0          1
    2       4.0           0          1
    3       3.0           0          1

## Conversión al formato Tensor
Como todas las entradas y salidas son numéricas, se puede convertir al formato Tensor. Cuando estén en dicho formato, ya pueden ser manipulados con las funciones de los tensores.

X: Entradas
y: Salidas

```python
X, y = torch.tensor(inputs.values), torch.tensor(outputs.values)
X,y
```

    (tensor([[3., 1., 0.],
             [2., 0., 1.],
             [4., 0., 1.],
             [3., 0., 1.]], dtype=torch.float64),
     tensor([127500, 106000, 178100, 140000]))

# Algebra lineal

## Escalares
Valores que constan de una sola cantidad numérica.

Si queremos convertir grados Fahrenheit a Celsius, se usa la siguiente expresión: $c = \frac{5}{9}(f-32)$, donde $\frac{5}{9}$  y 32 son escalares. Las letras $c$ y $f$ representan valores escalares desconocidos.

Las variables se denotan por letras en minúsculas en este apartado. $\mathbb R$ denota el espacio de todos los escalares de valor real.

$x \in \mathbb R$ es una forma de decir que $x$ es un escalar de valor real.

Un escalar es representado por un tensor con un solo elemento.

A continuación, se muestran 2 escalares y se realizan operaciones aritméticas.

```python
import torch
x = torch.tensor(3.0)
y = torch.tensor(2.0)
print(x+y) # suma
print(x*y) # multiplicación
print(x/y) # división
print(x**y) # exponenciación
```

    tensor(5.)
    tensor(6.)
    tensor(1.5000)
    tensor(9.)

## Vectores
Se puede pensar un vector como una simple lista de valores escalares. Estos serán llamados elementos (entradas o componentes) del vector.

En la notación matemática, se suele denotar a los vectores con letras minúsculas y en negrita: **x**,**y** y **z**.

Los vectores son tensores unidimensionales, de longitudes arbitrarias:

```python
x = torch.arange(4)
x
```

    tensor([0, 1, 2, 3])

Podemos referirnos al i-ésimo elemento de **x** mediante $x_i$, siendo $x_i$ un escalar.
En la literatura, por defecto a los vectores se consideran como vectores columnas.

\begin{equation}
x =\begin{pmatrix}
x_1 \\
x_2 \\
\vdots \\
x_n
\end{pmatrix}
\end{equation}

donde $x_1,...,x_n$ son elementos del vector, y accedemos a cada elemento mediante indexación en el tensor:

```python
x[3] # elemento 4 del vector
```

    tensor(3)

## **Longitud, dimensionalidad y forma**
Un vector es un arreglo de números. Cada vector tiene una longitud.

En notación matemática, si queremos decir que un vector **x** consiste de $n$ escalares de valor real, se puede expresar como **x** $\in \mathbb R^n$.

La longitud del vector se llama comúnmente como la dimensión del vector.

Podemos saber la longitud de un tensor mediante la función _len_.

```python
len(x) # longitud o dimensión de x
```

    4

Cuando un tensor representa un vector, se puede obtener su longitud al obtener su atributo de forma con _.shape_. La forma es una tupla que enlista la longitud a lo largo de cada eje del tensor, y al hablar de un vector, nos arroja su longitud:

```python
x.shape
```

    torch.Size([4])

## Matrices
Las matrices generalizan vectores de orden 1 a orden 2.
Se denotan típicamente con letras mayúsculas: **X**, **Y** y **Z**.
En código se representan como tensores con 2 ejes.

En notación matemática, se usa **A** $\in \mathbb R^{m \times n}$ para expresar que la matriz **A** consiste de $m$ filas y de $n$ columnas de escalares de valor real.

Se puede ver como una tabla donde cada elemento $a_{ij}$ pertenece a la i-ésima fila y j-ésima columna.

\begin{equation}
A =\begin{pmatrix}
a_{11} & a_{12} & \cdots & a_{1n}\\
a_{21} & a_{22} & \cdots & a_{2n}\\
\vdots & \vdots & \ddots & \vdots\\
a_{m1} & a_{m2} & \cdots & a_{mn}
\end{pmatrix}
\end{equation}

Si $m = n$ se dice que la matriz es cuadrada.

Para crear una matriz tenemos que especificar la forma con dos componentes, tal y como se muestra:

```python
A = torch.arange(20).reshape(5,4)
A
```

    tensor([[ 0,  1,  2,  3],
            [ 4,  5,  6,  7],
            [ 8,  9, 10, 11],
            [12, 13, 14, 15],
            [16, 17, 18, 19]])

Podemos acceder al elemento $a_{ij}$ de la matriz A, especificando los índices de las filas (i) y columnas (j), tal como $[\textbf{A}]_{i,j}$

**Transpuesta de una matriz**

La transpuesta de una matriz es cuando se intercambian filas por columnas de la matriz y se denota por $\textbf{A}^{T}$, y si tenemos que $\textbf{B} = \textbf{A}^{T}$, entonces $b_{ij} = a_{ji}$, para cualquier $i$ y $j$.

\begin{equation}
A ^T=\begin{pmatrix}
a_{11} & a_{21} & \cdots & a_{m1}\\
a_{12} & a_{22} & \cdots & a_{m2}\\
\vdots & \vdots & \ddots & \vdots\\
a_{1n} & a_{2n} & \cdots & a_{mn}
\end{pmatrix}
\end{equation}

```python
A.T # transpuesta de una matriz
```

    tensor([[ 0,  4,  8, 12, 16],
            [ 1,  5,  9, 13, 17],
            [ 2,  6, 10, 14, 18],
            [ 3,  7, 11, 15, 19]])

**Matriz simétrica**

Una matriz es simétrica si es igual a su transpuesta.
$\textbf{A} = \textbf{A}^{T}$

```python
B = torch.tensor([[1,2,3],[2,0,4],[3,4,5]]) # B es una matriz simétrica
```

```python
B == B.T # se iguala con su transpuesta y debe dar todos verdaderos
```

    tensor([[True, True, True],
            [True, True, True],
            [True, True, True]])

## Tensores
Los tensores nos dan una forma genérica de describir a los arreglos n-dimensionales con un número arbitrario de ejes.

Los vectores son tensores de primer orden, las matrices son tensores de segundo orden. Los tensores se denotan por letras mayusculas y un tipo de letras especial, y su mecanismo de indexación es similar al de matrices.

Cuando se trabaja con imágenes los tensores tienen una gran importancia, ya que éstas llegan como arreglos n-dimensionales con 3 ejes correspondientes a la altura, anchura y un eje de canal para apilar los canales de color (rojo, verde y azul).

Ejemplo de tensor:

```python
X = torch.arange(24).reshape(2,3,4)
X
```

    tensor([[[ 0,  1,  2,  3],
             [ 4,  5,  6,  7],
             [ 8,  9, 10, 11]],

            [[12, 13, 14, 15],
             [16, 17, 18, 19],
             [20, 21, 22, 23]]])

## Propiedades básicas aritmética tensorial
Dados dos tensores de la misma forma, el resultado de cualquier operación elemental binaria será un tensor con la misma forma, por ejemplo, si se suman dos matrices de la misma forma, se realiza una suma elemental sobre estas dos matrices.

```python
A = torch.arange(20,dtype=torch.float32).reshape(5,4)
B = A.clone() # asigna una copia de A a B asignando nueva memoria
A,A+B
```

    (tensor([[ 0.,  1.,  2.,  3.],
             [ 4.,  5.,  6.,  7.],
             [ 8.,  9., 10., 11.],
             [12., 13., 14., 15.],
             [16., 17., 18., 19.]]), tensor([[ 0.,  2.,  4.,  6.],
             [ 8., 10., 12., 14.],
             [16., 18., 20., 22.],
             [24., 26., 28., 30.],
             [32., 34., 36., 38.]]))

La multiplicación elemental de dos matrices de denomina producto Hadamard (en notación matemática $\odot$).

Si tenemos una matriz $\textbf{B} \in \mathbb R^{m\times n}$, con los elementos de filas $i$ y columnas $j$ como $b_{ij}$. El producto Hadamard de matrices $\textbf{A}$ y $\textbf{B}$, se encuentra definida como:

\begin{equation}
A \odot B = \begin{pmatrix}
a_{11}b_{11} & a_{12}b_{12} & \cdots & a_{1n}b_{1n}\\
a_{21}b_{21} & a_{22}b_{22} & \cdots & a_{2n}b_{2n}\\
\vdots & \vdots & \ddots & \vdots\\
a_{m1}b_{m1} & a_{m2}b_{m2} & \cdots & a_{mn}b_{mn}
\end{pmatrix}
\end{equation}

```python
A*B # producto Hadamard
```

    tensor([[  0.,   1.,   4.,   9.],
            [ 16.,  25.,  36.,  49.],
            [ 64.,  81., 100., 121.],
            [144., 169., 196., 225.],
            [256., 289., 324., 361.]])

Multiplicar o sumar un tensor por un escalar, tampoco cambia la forma del tensor. Cada elemento del tensor será sumado o multiplicado por el escalar.

En el siguiente código se suma un 2 al tensor X y también se multiplica por 2 al tensor X.

```python
a = 2
X = torch.arange(24).reshape(2, 3, 4)
a + X, (a * X).shape
```

    (tensor([[[ 2,  3,  4,  5],
              [ 6,  7,  8,  9],
              [10, 11, 12, 13]],

             [[14, 15, 16, 17],
              [18, 19, 20, 21],
              [22, 23, 24, 25]]]), torch.Size([2, 3, 4]))

## Reducción
Una operación útil que se puede realizar con tensores es calcular la suma de sus elementos. En código basta con llamar a la función para calcular la suma:

```python
x = torch.arange(4,dtype = torch.float32)
x,x.sum()
```

    (tensor([0., 1., 2., 3.]), tensor(6.))

Podemos expresar sumas sobre los elementos de tensores de forma arbitrarria.
Por ejemplo, la suma de los elementos de una matriz $\textbf{A}$ de $m \times n$ se puede escribir como:

$\sum_{i = 1}^{m}\sum_{j = 1}^{n}a_{ij}$

```python
A.shape, A.sum()
A
```

    tensor([[ 0.,  1.,  2.,  3.],
            [ 4.,  5.,  6.,  7.],
            [ 8.,  9., 10., 11.],
            [12., 13., 14., 15.],
            [16., 17., 18., 19.]])

Por defecto, la suma reduce un tensor a lo largo de todos sus ejes a un escalar, no obstante, podemos especificiar los ejes a lo largo de los cuales se reduce el tensor mediante la suma.

Si usamos las matrices como ejemplo, y reducimos la dimensión de la fila (eje 0) sumando los elementos de todas las filas, especificamos eje = 0 al llamar a la función.

Al realizar esto, se sumaría la primer columna y se colocaría el resultado en el primer elemento del eje 0, para la segunda columna es lo mismo, colocándose el resultado en el segundo elemento del eje 0. Esto se puede ver a continuación:

```python
A_sum_axis0 = A.sum(axis=0)
A_sum_axis0, A_sum_axis0.shape
```

    (tensor([40., 45., 50., 55.]), torch.Size([4]))

En cambio, si se coloca como parámetro al eje 1, se hace la suma por filas (se suman los elementos de la fila 1, de la fila 2, así sucesivamente), es decir, sumando los elementos de las columnas.

Con el ejemplo que se ha utilizado resulta lo siguiente:

```python
A_sum_axis1 = A.sum(axis=1)
A_sum_axis1, A_sum_axis1.shape
```

    (tensor([ 6., 22., 38., 54., 70.]), torch.Size([5]))

Si reducimos la matriz a lo largo de las filas y columnas es equivalente a sumar todos los elementos de la matriz:

```python
A.sum(axis=[0, 1]) # esto es igual a A.sum()
```

    tensor(190.)

Una cantidad relacionada es la media o promedio. Esto se obtiene dividiendo la suma entre el número total de elementos. Mediante código resulta:

```python
A.mean(), A.sum() / A.numel() # se puede realizar de ambas formas
```

    (tensor(9.5000), tensor(9.5000))

La media también puede reducir el tensor a lo largo de los ejes especificados, como ocurría con la suma:

```python
A.mean(axis=0), A.sum(axis=0) / A.shape[0] # se puede hacer de ambas formas
```

    (tensor([ 8.,  9., 10., 11.]), tensor([ 8.,  9., 10., 11.]))

### **Suma sin reducción**
A veces se puede mantener el número de ejes sin cambios cuando se llama a la función suma o media, mediante el parámetro _keepdims = True_.

```python
sum_A = A.sum(axis=1, keepdims=True)
sum_A
```

    tensor([[ 6.],
            [22.],
            [38.],
            [54.],
            [70.]])

Como la sum_A sigue conservando sus dos ejes después de sumar cada fila, se puede dividir A entre la sum_A mediante difusión:

```python
A/sum_A
```

    tensor([[0.0000, 0.1667, 0.3333, 0.5000],
            [0.1818, 0.2273, 0.2727, 0.3182],
            [0.2105, 0.2368, 0.2632, 0.2895],
            [0.2222, 0.2407, 0.2593, 0.2778],
            [0.2286, 0.2429, 0.2571, 0.2714]])

Si queremos calcular la suma acumulativa de elementos de A a lo largo de algún eje, podemos llamar a la función _cumsum_, es decir, se conserva la forma de la matriz pero en los ejes se va poniendo la suma en base a si es con respecto a las filas o a las columnas. Esto se observa en lo siguiente:

```python
A.cumsum(axis=0)
```

    tensor([[ 0.,  1.,  2.,  3.],
            [ 4.,  6.,  8., 10.],
            [12., 15., 18., 21.],
            [24., 28., 32., 36.],
            [40., 45., 50., 55.]])

## Productos puntos
Dados dos vectores $\textbf{x}$ y $\textbf{y} \in \mathbb R^d$, su producto punto denotado por $\textbf{x}^T\textbf{y}$ ó $⟨\textbf{x},\textbf{y}⟩$, es una suma sobre los productos de los elementos en la misma posición, es decir, se multiplica el elemento 1 de un vector con el elemento 1 del otro, y así con todos los elementos y finalmente se hace la suma; en notación matemática es:
$\textbf{x}^T\textbf{y} = \sum_{i=1}^d x_iy_i$:

```python
x = torch.arange(4, dtype=torch.float32) # vector x
y = torch.ones(4,dtype = torch.float32) # vector y
x, y, torch.dot(x, y)
```

    (tensor([0., 1., 2., 3.]), tensor([1., 1., 1., 1.]), tensor(6.))

El producto punto se puede expresar mediante la multiplicación po elementos y luego la suma, en lugar de usar _dot_:

```python
torch.sum(x * y)
```

    tensor(6.)

El producto punto expresa una media ponderada.

Si tenemos dos vectores normalizados (su magnitud es la unidad), el producto punto entre ellos expresan el coseno del ángulo entre ellos.

## Productos Matriz-Vector
Una matriz $\textbf{A}$ en términos de sus vectores fila se visualiza como

\begin{equation}
\textbf{A}   = \begin{pmatrix}
\textbf{a}_1^T\\
\textbf{a}_2^T \\
\vdots \\
\textbf{a}_m^T\end{pmatrix}
\end{equation}

donde cada $\textbf{a}_1^T \in \mathbb R^n$ es un vector fila que representa la i-ésima fila de la matriz $\textbf{A}$.

El producto matriz-vector $\textbf{A}\textbf{x}$ es un simple vector columna de longitud $m$, cuyo i-ésimo elemento es el producto punto $\textbf{a}_1^T\textbf{x}$:

\begin{equation}
\textbf{Ax}  = \begin{pmatrix}
\textbf{a}_1^T\textbf{x}\\
\textbf{a}_2^T\textbf{x} \\
\vdots \\
\textbf{a}_m^T\textbf{x}\end{pmatrix}
\end{equation}

Podemos pensar a la multiplicación por una matriz $\textbf{A}$ como una transformación que proyecta vectores de $\mathbb R^n$ a $\mathbb R^m$.

Para expresar los productos matriz-vector con tensores se utiliza la función _mv_. La dimensión de la columna de la matriz $\textbf{A}$ (longitud a lo largo del eje 1), tiene que ser igual a la dimensión del vector $\textbf{x}$:

```python
A.shape, x.shape, torch.mv(A, x)
```

    (torch.Size([5, 4]), torch.Size([4]), tensor([ 14.,  38.,  62.,  86., 110.]))

## Multiplicación Matriz-Matriz
Se tiene a las dos matrices $\textbf{A} \in \mathbb R^{n \times k}$ y $\textbf{B} \in \mathbb R^{k \times m}$, definidas como:

\begin{equation}
\textbf{A} = \begin{pmatrix}
a_{11} & a_{12} & \cdots & a_{1k}\\
a_{21} & a_{22} & \cdots & a_{2k}\\
\vdots & \vdots & \ddots & \vdots\\
a_{n1} & a_{n2} & \cdots & a_{nk}
\end{pmatrix}
\end{equation}

\begin{equation}
\textbf{B} = \begin{pmatrix}
b_{11} & b_{12} & \cdots & b_{1m}\\
b_{21} & b_{22} & \cdots & b_{2m}\\
\vdots & \vdots & \ddots & \vdots\\
b_{k1} & b_{k2} & \cdots & b_{nm}
\end{pmatrix}
\end{equation}

Denotamos como $\textbf{a}_i^T \in \mathbb R^k$ al vector fila que representa la i-ésima fila de la matriz $\textbf{A}$ y $\textbf{b}_j^T \in \mathbb R^k$ al vector columna de la j-ésima columna de la matriz $\textbf{B}$.

Para producir el producto matricial $\textbf{C} = \textbf{AB}$, se piensan a las matrices $\textbf{A}$ y $\textbf{B}$ en términos de vectores fila, y columna, respectivamente:

\begin{equation}
\textbf{A}  = \begin{pmatrix}
\textbf{a}_1^T\\
\textbf{a}_2^T \\
\vdots \\
\textbf{a}_n^T\end{pmatrix}
\end{equation}

\begin{equation}
\textbf{B}  = \begin{pmatrix}
\textbf{b}_1 & \textbf{b}_2 & \cdots & \textbf{b}_m\\
\end{pmatrix}
\end{equation}

La matriz resultante $\textbf{C} \int \mathbb R^{n \times m}$ se produce calculando cada elemento $c_{ij}$ mediante el producto punto $\textbf{a}_i^T\textbf{b}_j$

\begin{equation}
\textbf{C}  = \textbf{AB} = \begin{pmatrix}
\textbf{a}_1^T\\
\textbf{a}_2^T \\
\vdots \\
\textbf{a}_n^T\end{pmatrix}\begin{pmatrix}
\textbf{b}_1 & \textbf{b}_2 & \cdots & \textbf{b}_m\\
\end{pmatrix} = \begin{pmatrix}
\textbf{a}_1^T\textbf{b}_1 & \textbf{a}_1^T\textbf{b}_2 & \cdots & \textbf{a}_1^T\textbf{b}_m\\
\textbf{a}_2^T\textbf{b}_1 & \textbf{a}_2^T\textbf{b}_2 & \cdots & \textbf{a}_2^T\textbf{b}_m\\
\vdots & \vdots & \ddots & \vdots\\
\textbf{a}_n^T\textbf{b}_1 & \textbf{a}_n^T\textbf{b}_2 & \cdots & \textbf{a}_n^T\textbf{b}_m
\end{pmatrix}
\end{equation}

En esta multiplicación, si la primera matriz tiene dimensión ($n,k$) y la segunda tiene dimensión ($k,m$), vemos que la primer matriz debe tener las mismas columnas que filas la segunda matriz. El resultado tendrá una dimensión igual a los extremos de las dos matrices multiplicadas, es decir, ($n,m$).

En código se utiliza la función _mm_.

```python
B = torch.ones(4, 3)
torch.mm(A, B)
```

    tensor([[ 6.,  6.,  6.],
            [22., 22., 22.],
            [38., 38., 38.],
            [54., 54., 54.],
            [70., 70., 70.]])

## Norma
La norma nos indica el tamaño de un vector, siendo aquí referente a la magnitud de los componentes y no a su dimensionalidad.

Una norma vectorial es una función $f$ que mapea un vector a un escalar satisfaciendo diversas propiedades.

**Propiedades**\
Dado cualquier vector $\textbf{x}$:

* Si se escalan todos los elementos del vector por un factor constante $\alpha$, la norma también se escala por el valor absoluto del mismo factor constante:

$f(\alpha\textbf{x}) = |\alpha|f(\textbf{x})$

* Desigualdad del triangulo:

$f(\textbf{x}+\textbf{y}) \leq f(\textbf{x}) + f(\textbf{y})$

* La norma debe no debe ser negativa:

$f(\textbf{x}) \geq 0$

* La norma más pequeña se obtiene sólo si el vector consiste de puros ceros:

$\forall_i ,[\textbf{x}_i]$ = 0 $↔ f(\textbf{x}) = 0$

La distancia euclidea es una norma, es la norma **$L_2$**:

La norma **$L_2$** de $\textbf{x}$ es la raíz cuadrada de la suma de los cuadrados de los elementos del vector:

$∥\textbf{x}∥_2 = \sqrt{\sum_{i = 1}^n x_i^2}$

La norma **$L_2$** se puede calcular en código mediante:

```python
u = torch.tensor([3.0, -4.0])
torch.norm(u) # norma L2 del vector u
```

    tensor(5.)

La norma **$L_1$** también es frecuente, y se expresa como la suma de los valores absolutos de los elementos del vector:

$∥\textbf{x}∥_1 = \sum_{i = 1}^n |x_i|$

Para calcular esta norma mediante código, se compone la función de valor absoluto con la suma de los elementos:

```python
torch.abs(u).sum() # norma L1 del vector u

```

    tensor(7.)

Estas dos normas, son casos especiales de la norma **$L_p$** más general:

$∥\textbf{x}∥_p = (\sum_{i = 1}^n |x_i|^p)^{\frac{1}{p}}$

Para el caso de matrices y similar a la norma **$L_2$**, la norma Frobenius de una matriz $\textbf{X} \in \mathbb R^{m \times n}$ es la raíz cuadrada de la suma de los cuadrados de los elementos de la matriz:

$∥\textbf{X}∥_F = \sqrt{\sum_{i = 1}^m\sum_{j = 1}^n x_{ij}^2}$

Esta norma satisface las propiedades de las normas vectoriales.

Se calcula en código mediante:

```python
torch.norm(torch.ones((4,9)))
```

    tensor(6.)

# Cálculo
Las aplicaciones más críticas del cálculo diferencial son los problemas de optimización.

En el aprendizaje profundo, se entrenan modelos actualizándolos para que mejoren conforme se ven más datos, mejorar hace referencia a minimizar una función de pérdida.

Lo que importa es producir un modelo que funcione bien con datos que nunca hemos visto, pero ajustando el modelo a los datos que realmente vemos.

El ajuste de modelos se puede dividir en dos aspectos clave:

i) Optimización: proceso de ajuste de nuestros modelos a datos observados.

ii) Generalización: principios matemáticos y conocimiento de profesionales que orientan sobre cómo producir modelos cuya validez se extiende más allá del conjunto exacto de ejemplos de datos utilizados para entrenarlos.

## Diferenciación y derivadas

En el aprendizaje automático, se eligen funciones de pérdida que son diferenciables respecto a parámetros del modelo, esto nos dice que podemos saber la rapidez con que aumentaría o disminuiría la pérdida si aumentáramos o disminuyéramos ese parámetros en una cantidad infinitesimal.

Supongamos una función $f: \mathbb R \to \mathbb R$, cuya entrada y salida son escalares. La derivada de $f$ se define como:

$f'(x) = \lim_{h \to 0} \frac{f(x+h)-f(x)}{h}$

si el límite existe.

Si $f'(a)$ existe, $f$ es diferenciable en $a$. Así mismo, si $f$ es diferenciable en cada número de un intervalo, entonces $f$ es diferenciable en este intervalo. La derivada la podemos interpretar como la tasa de cambio instantánea de $f(x)$ con respecto a $x$.

Ejemplo:

Tenemos la función $u = f(x) = 3x^2-4x$

```python
%matplotlib inline
import numpy as np
from IPython import display
#from d2l import torch as d2l

def f(x):
  return 3*x**2-4*x
```

Conforme h se acerca a 0, y estableciendo $x = 1$, la derivada va teniendo un valor igual a 2, esto se muestra a continuación:

```python
def numerical_lim(f,x,h):
  return (f(x+h)-f(x))/h # derivada de f

h = 0.1

for i in range(5):
  print(f"h = {h:.5f}, limite = {numerical_lim(f,1,h):.5f}")
  h *= 0.1
```

    h = 0.10000, limite = 2.30000
    h = 0.01000, limite = 2.03000
    h = 0.00100, limite = 2.00300
    h = 0.00010, limite = 2.00030
    h = 0.00001, limite = 2.00003

**Expresiones equivalentes para la derivada**

$f'(x) = y' = \frac{dy}{dx} = \frac{df}{dx} = \frac{d}{dx}f(x) = Df(x) = D_xf(x)$

los simbolos $\frac{d}{dx}$ y $D$ son operadores de diferenciación.

**Reglas para diferenciar funciones comunes:**

* $DC = 0$ ($C$ es una constante)
* $Dx^n = nx^{n-1}$ ($n$ cualquier número real)
* $De^x = e^x$
* $D$ln$(x)$ = $\frac{1}{x}$

**Reglas de derivación**

$f$ y $g$ son funciones diferenciables y $C$ es una constante:

* $\frac{d}{dx}[Cf(x)] = C\frac{d}{dx}f(x)$
* Regla de la suma

$\frac{d}{dx}[f(x)+g(x)] = \frac{d}{dx}f(x)+\frac{d}{dx}g(x)$

* Regla del producto

$\frac{d}{dx}[f(x)g(x)] = f(x)\frac{d}{dx}[g(x)]+g(x)\frac{d}{dx}[f(x)]$

* Regla del cociente

$\frac{d}{dx}[\frac{f(x)}{g(x)}] = \frac{g(x)\frac{d}{dx}[f(x)]-f(x)\frac{d}{dx}[g(x)]}{[g(x)]^2}$

Para visualizar la interpretación de las derivadas, se utilzará la librería matplotlib. Además, funciones como _use_svg_display_ especifica que el paquete matplotlib produzca las figuras svg para obtener imágenes más nítidas.

Tenemos primero la siguiente función:

```python
def use_svg_display():
    """Usa el formato svg para desplegar un gráfico en Jupyter."""
    display.set_matplotlib_formats('svg')
```

Ahora se define la función set_figsize para especificar los tamaños de las figuras.

```python
def set_figsize(figsize=(3.5, 2.5)):
    """Set the figure size for matplotlib."""
    use_svg_display()
    d2l.plt.rcParams['figure.figsize'] = figsize
```

La siguiente función set_axes establece las propiedades de los ejes de las figuras producidas por matplotlib.

```python
def set_axes(axes, xlabel, ylabel, xlim, ylim, xscale, yscale, legend):
    """Configura los ejes para matplotlib"""
    axes.set_xlabel(xlabel)
    axes.set_ylabel(ylabel)
    axes.set_xscale(xscale)
    axes.set_yscale(yscale)
    axes.set_xlim(xlim)
    axes.set_ylim(ylim)
    if legend:
        axes.legend(legend)
    axes.grid()
```

La siguiente función plot grafica múltiples curvas:

```python
def plot(X, Y=None, xlabel=None, ylabel=None, legend=None, xlim=None,
         ylim=None, xscale='linear', yscale='linear',
         fmts=('-', 'm--', 'g-.', 'r:'), figsize=(3.5, 2.5), axes=None):
    """Grafica puntos de datos."""
    if legend is None:
        legend = []

    set_figsize(figsize)
    axes = axes if axes else d2l.plt.gca()

    # Regresa True si X (tensor o lista) tiene 1 eje
    def has_one_axis(X):
        return (hasattr(X, "ndim") and X.ndim == 1 or isinstance(X, list)
                and not hasattr(X[0], "__len__"))

    if has_one_axis(X):
        X = [X]
    if Y is None:
        X, Y = [[]] * len(X), X
    elif has_one_axis(Y):
        Y = [Y]
    if len(X) != len(Y):
        X = X * len(Y)
    axes.cla()
    for x, y, fmt in zip(X, Y, fmts):
        if len(x):
            axes.plot(x, y, fmt)
        else:
            axes.plot(y, fmt)
    set_axes(axes, xlabel, ylabel, xlim, ylim, xscale, yscale, legend)
```

Podemos graficar la función $y = 2x-3$ en el punto $x = 1$.

```python
x = np.arange(0, 3, 0.1)
plot(x, [f(x), 2 * x - 3], 'x', 'f(x)', legend=['f(x)', 'Tangent line (x=1)'])
```

## Derivadas parciales
 En el aprendizaje profundo, las funciones suelen depender de muchas variables, es por eso que son de importancia las derivadas parciales, en donde se derivan funciones multivariables respecto a variables específicas.

 Sea $y = f(x_1,x_2,...,x_n)$ una función con $n$ variables. La derivada parcial de $y$ respecto a el i-ésimo parámetro $x_i$ es:

 $\frac{\partial y}{\partial x_i} = \lim_{h \to 0} \frac{f(x_1,...,x_{i-1},x_i+h,x_{i+1},...,x_n)-f(x_1,...,x_i,...,x_n)}{h}$

 Para calcular $\frac{\partial y}{\partial x_i}$ simplemente podemos tratar a $x_1,...,x_{i-1},x_{i+1},...,x_n$ como constantes, y calcular la derivada de $y$ con respecto a $x_i$.

 ## Gradientes
 Podemos concatenar las derivadas parciales de una función multivariable con respecto a todas sus variables para obtener el vector gradiente de la función.

 Supongamos que la entrada de la función $f : \mathbb R^n \to \mathbb R$ es un vector $n-$dimensional $\textbf{x} = [x_1,x_2,...,x_n]$ y la salida es un escalar. El gradiente de la función $f(\textbf{x})$ con respecto a $\textbf{x}$ es un vector de $n$ derivadas parciales:

 $\nabla_xf(\textbf{x}) = [\frac{\partial f(\textbf{x})}{\partial x_1},\frac{\partial f(\textbf{x})}{\partial x_2},...,\frac{\partial f(\textbf{x})}{\partial x_n}]^T$

**Reglas utilizadas al diferenciar funciones multivariadas**

* $\forall \textbf{A} \in \mathbb R^{m \times n}, \nabla_x \textbf{Ax} = \textbf{A}^T$
*  $\forall \textbf{A} \in \mathbb R^{n \times m}, \nabla_x \textbf{x}^T\textbf{A} = \textbf{A}$
*  $\forall \textbf{A} \in \mathbb R^{n \times n}, \nabla_x \textbf{x}^T\textbf{A}\textbf{x} = (\textbf{A}+\textbf{A}^T)\textbf{x}$
* $\nabla_x∥\textbf{x}∥^2 = \nabla_x \textbf{x}^T\textbf{x} = 2\textbf{x}$

## Regla de la cadena
La regla de la cadena permite diferencias funciones compuestas.

Consideremos funciones de una variable. Supongamos que las funciones $y = f(u)$ y $u = g(x)$ son ambas diferenciables, entonces la regla de la cadena establece que

$\frac{dy}{dx} = \frac{dy}{du}\frac{du}{dx}$

Funciones multivariables. Supongamos que la funcipon diferenciable $y$ tiene variables $u_1,u_2,...,u_m$, donde cada función diferenciable $u_i$ tiene variables $x_1,x_2,...,x_n$. Por tanto, $y$ es una función de $x_1,x_2,...,x_n$. La regla de la cadena resulta:

$\frac{dy}{dx_i} = \frac{dy}{du_1}\frac{du_1}{dx_i}+\frac{dy}{du_2}\frac{du_2}{dx_i}+...+\frac{dy}{du_m}\frac{du_m}{dx_i}$

para cualquier $i = 1,2,...,n$.

## Diferenciación automática
La diferenciación es crucial en casi todos los algoritmos de optimización del aprendizaje profundo.

Basándonos en nuestro modelo diseñado, el sistema construye un gráfico computacional, rastreando qué datos se combinan a través de qué operaciones para producir la salida.
La diferenciación automática permite al sistema retropropagar posteriormente los gradientes. Con retropropagar nos referimos a recorrer el gráfico computacional, rellenando las derivadas pariales con respecto a cada parámetro.

#### Un ejemplo sencillo

Supongamos que queremos diferenciar la función $ y = 2\textbf{x}^T\textbf{x}$ con respecto al vector columna $\textbf{x}$. Para esto, se crea la variable $\textbf{x}$ y se le asigna un valor inicial:

```python
import torch
x = torch.arange(4.0)
x
```

    tensor([0., 1., 2., 3.])

Antes de calcular el gradiente, se necesita un lugar para almacenarlo. Se debe tomar en cuenta que no se debe asignar nueva memoria cada vez que se tome una derivada respecto a un parámetro, ya que, probablemente se actualice millones de veces.

```python
x.requires_grad_(True) # esto es lo mismo a poner x = torch.arange(4.0,requires_grad = True)
x.grad # el valor por defecto es None
```

```python
y = 2*torch.dot(x,x) # funcion y
y
```

    tensor(28., grad_fn=<MulBackward0>)

El vector x tiene longitud 4, y al hacer producto punto con él mismo, se obtiene un escalar que se asigna a y. El gradiente de $y$ respecto a cada componente de $\textbf{x}$ se puede obtener llamando a la función de retropropagación, y luego imprimimos el gradiente:

```python
y.backward()
x.grad
```

    tensor([ 0.,  4.,  8., 12.])

El gradiente de $y = 2\textbf{x}^T\textbf{x}$ con respecto a $\textbf{x}$ debería ser $4\textbf{x}$. Para verificar comparamos el gradiente obtenido con el valor que debe ser y comprobamos si la operación fue correcta:

```python
x.grad == 4*x
```

    tensor([True, True, True, True])

Calculando otra función de $x$, tenemos lo siguiente.
El gradiente se acumula, entonces se debe limpiar previamente:

```python
x.grad.zero_() # limpiar gradiente
y = x.sum() # funcion que es la suma de los elementos del vector x
y.backward() # retropropagación
x.grad # imprime gradiente
```

    tensor([1., 1., 1., 1.])

## Retropropagación de variables no escalares
La interpretación de la diferenciación de un vector $\textbf{y}$ (que no es ecalar) con respecto a un vector $\textbf{x}$ es una matriz. Para vectores de mayor orden y dimensión, el resultado podría ser un tensor de alto orden.

En estos ejemplos no se pretende calcular la matriz de diferenciación, sino la suma de las derivadas parciales calculadas indivialmente para cada ejemplo en el lote.

Llamar a la función _backward_ en una función no escalar requiere pasar un argumento "gradient" que especifica el gradiente de la función diferenciada con respecto a "self". En este ejemplo, simplemente queremos sumar las derivadas parciales, por lo que pasar un gradiente de puros unos es adecuado:

```python
x.grad.zero_()
y = x*x

y.sum().backward() # esto es igual a y.backward(torch.ones(len(x)))
x.grad
```

    tensor([0., 2., 4., 6.])

## Cálculo de desprendimiento

Digamos que tenemos a una función $\textbf{y}$ como una función de $\textbf{x}$, y una función $\textbf{z}$ como función de $\textbf{y}$ y de $\textbf{x}$. Si quisiéramos calcular el gradiente de $\textbf{z}$ con respecto a $\textbf{x}$, y tratar a $\textbf{y}$ como una constante.

Entonces podemos separar $\textbf{y}$ para devolver una nueva variable $\textbf{u}$ que tiene el mismo valor de $\textbf{y}$ pero que descarta cualquier información sobre cómo se calculó y en el gráfico computacional, es decir, el gradiente no fluirá hacia atrás a través de $\textbf{u}$ hasta $\textbf{x}$. De este modo, la siguiente función de retropropagación calcula la derivada parcial de $\textbf{z} = \textbf{u}*\textbf{x}$ con respecto a $\textbf{x}$, tratando a $\textbf{u}$ como una constante, en lugar de hacer la derivada parcial de $\textbf{z} = \textbf{x}*\textbf{x}*\textbf{x}$ con respecto a $\textbf{x}$.

Esto se muestra a continuación:

```python
x.grad.zero_()
y = x * x
u = y.detach()
z = u * x

z.sum().backward()
x.grad == u
```

    tensor([True, True, True, True])

## Cálculo del gradiente del flujo de control en Python
En el siguiente código, el número de iteraciones del bucle while y la evaluación de la sentencia if depende del valor de la entrada a.

```python
def f(a):
    b = a * 2
    while b.norm() < 1000:
        b = b * 2
    if b.sum() > 0:
        c = b
    else:
        c = 100 * b
    return c

```

Calculando el gradiente:

```python
a = torch.randn(size=(), requires_grad=True)
d = f(a)
d.backward()
```

En la función $f$ definida previamente es lineal a trozos en su entrada $a$, es decir, para cualquier $a$ existe algún escalar constante $k$ tal que, $f(a) = k*a$, donde el valor de $k$ depende de la entrada $a$. En consecuencia $d/a$ nos permite verificar que el gradiente es correcto.

```python
a.grad == d / a
```

    tensor(True)

# Probabilidad
## Teoría básica de la probabilidad

Si lanzamos un dado y queremos saber cuál es la probabilidad de ver un 1 en lugar de otro dígito. Si el dado es justo, los seis resultados $\{1,...,6\}$ tienen la misma probabilidad de ocurrir. Decimos entonces que el 1 puede salir con una probabilidad de $\frac{1}{6}$.

Un enfoque natural para cada valor es tomar el recuento individual para un valor y dividirlo por el número total de lanzamientos, dándonos una estimación de la probabilidad de un evento determinado.

Importamos lo siguiente:

```python
%matplotlib inline
import torch
from torch.distributions import multinomial
from d2l import torch as d2l
```

En estadística se llama muestreo al proceso de extraer ejemplos de las distribuciones de probabilidad. La distribución que asigna probabilidades a un número de opciones discretas se llama distribución multinomial.

Para extraer una muestra, se pasa un vector de probabilidades. La salida es un vector de la misma longitud: su valor en el índice $i$ es el número de veces que el resultado de la muestra corresponde a $i$.

```python
fair_probs = torch.ones([6]) / 6
multinomial.Multinomial(1, fair_probs).sample()
```

    tensor([0., 0., 0., 0., 1., 0.])

Si se ejecuta el código anterior varias veces, se obtendrán valores aleatorios. La función utilizada soporta la extracción de múltiples muestras a la vez, devoldiendo un array de muestras independientes.

```python
multinomial.Multinomial(10, fair_probs).sample()

```

    tensor([2., 1., 1., 2., 4., 0.])

El código anterior ya no solo simula la tirada de un dado una vez, sino que la simula para 10 tiradas, y podemos ver cuantas veces salió cada número. En concreto, calculamos la frecuencia relativa como estimación de la probabilidad real.

Para 1000 tiradas:

```python
counts = multinomial.Multinomial(1000, fair_probs).sample()
counts / 1000  # frecuencia relativa como estimación
```

    tensor([0.1670, 0.1820, 0.1710, 0.1600, 0.1640, 0.1560])

Las probabilidades obtenidas parecen bastante buenas, debido a que la probabilidad real antes mencionada es de $\frac{1}{6} ≈ 0.167$.

Podemos visualizar cómo estas probabilidades convergen con el tiempo hacia la probabilidad real.

Realizando 500 grupos de experimentos en los que cada grupo extrae 10 muestras:

```python
counts = multinomial.Multinomial(10, fair_probs).sample((500,))
cum_counts = counts.cumsum(dim=0)
estimates = cum_counts / cum_counts.sum(dim=1, keepdims=True)

d2l.set_figsize((6, 4.5))
for i in range(6):
    d2l.plt.plot(estimates[:, i].numpy(),
                 label=("P(die=" + str(i + 1) + ")"))
d2l.plt.axhline(y=0.167, color='black', linestyle='dashed')
d2l.plt.gca().set_xlabel('Groups of experiments')
d2l.plt.gca().set_ylabel('Estimated probability')
d2l.plt.legend();
```

Cada curva corresponde a uno de los seis valores del dado. La línea negra discontinua da la verdadera probabilidad subyacente. A medida que se obtienen más datos realizando más experimentos, las 6 curvas convergen hacia la verdadera probabilidad.

# Documentación
## Cómo encontrar todas las funciones y clases de un módulo

Para saber qué funciones y clases se pueden llamar en un módulo, se utiliza la función dir.
Por ejemplo, con el módulo para generar números aleatorios:

```python
import torch

print(dir(torch.distributions))
```

    ['AbsTransform', 'AffineTransform', 'Bernoulli', 'Beta', 'Binomial', 'CatTransform', 'Categorical', 'Cauchy', 'Chi2', 'ComposeTransform', 'ContinuousBernoulli', 'CorrCholeskyTransform', 'Dirichlet', 'Distribution', 'ExpTransform', 'Exponential', 'ExponentialFamily', 'FisherSnedecor', 'Gamma', 'Geometric', 'Gumbel', 'HalfCauchy', 'HalfNormal', 'Independent', 'IndependentTransform', 'Kumaraswamy', 'LKJCholesky', 'Laplace', 'LogNormal', 'LogisticNormal', 'LowRankMultivariateNormal', 'LowerCholeskyTransform', 'MixtureSameFamily', 'Multinomial', 'MultivariateNormal', 'NegativeBinomial', 'Normal', 'OneHotCategorical', 'OneHotCategoricalStraightThrough', 'Pareto', 'Poisson', 'PowerTransform', 'RelaxedBernoulli', 'RelaxedOneHotCategorical', 'ReshapeTransform', 'SigmoidTransform', 'SoftmaxTransform', 'StackTransform', 'StickBreakingTransform', 'StudentT', 'TanhTransform', 'Transform', 'TransformedDistribution', 'Uniform', 'VonMises', 'Weibull', '__all__', '__builtins__', '__cached__', '__doc__', '__file__', '__loader__', '__name__', '__package__', '__path__', '__spec__', 'bernoulli', 'beta', 'biject_to', 'binomial', 'categorical', 'cauchy', 'chi2', 'constraint_registry', 'constraints', 'continuous_bernoulli', 'dirichlet', 'distribution', 'exp_family', 'exponential', 'fishersnedecor', 'gamma', 'geometric', 'gumbel', 'half_cauchy', 'half_normal', 'identity_transform', 'independent', 'kl', 'kl_divergence', 'kumaraswamy', 'laplace', 'lkj_cholesky', 'log_normal', 'logistic_normal', 'lowrank_multivariate_normal', 'mixture_same_family', 'multinomial', 'multivariate_normal', 'negative_binomial', 'normal', 'one_hot_categorical', 'pareto', 'poisson', 'register_kl', 'relaxed_bernoulli', 'relaxed_categorical', 'studentT', 'transform_to', 'transformed_distribution', 'transforms', 'uniform', 'utils', 'von_mises', 'weibull']

De forma general, se pueden ignorar las funciones que empiezan y terminan con _ (son objetos especiales en Python) o las funciones que empizan solo con _ (son normalmente funciones internas).

#### Encontrar el uso de funciones y clases específicas

Para obtener instrucciones más específicas sobre cómo utilizar una determinada función o clase, podemos invocar a la función de ayuda.

Ejemplo: función ones de los tensores.

```python
help(torch.ones)
```

    Help on built-in function ones:

    ones(...)
        ones(*size, *, out=None, dtype=None, layout=torch.strided, device=None, requires_grad=False) -> Tensor

        Returns a tensor filled with the scalar value `1`, with the shape defined
        by the variable argument :attr:`size`.

        Args:
            size (int...): a sequence of integers defining the shape of the output tensor.
                Can be a variable number of arguments or a collection like a list or tuple.

        Keyword arguments:
            out (Tensor, optional): the output tensor.
            dtype (:class:`torch.dtype`, optional): the desired data type of returned tensor.
                Default: if ``None``, uses a global default (see :func:`torch.set_default_tensor_type`).
            layout (:class:`torch.layout`, optional): the desired layout of returned Tensor.
                Default: ``torch.strided``.
            device (:class:`torch.device`, optional): the desired device of returned tensor.
                Default: if ``None``, uses the current device for the default tensor type
                (see :func:`torch.set_default_tensor_type`). :attr:`device` will be the CPU
                for CPU tensor types and the current CUDA device for CUDA tensor types.
            requires_grad (bool, optional): If autograd should record operations on the
                returned tensor. Default: ``False``.

        Example::

            >>> torch.ones(2, 3)
            tensor([[ 1.,  1.,  1.],
                    [ 1.,  1.,  1.]])

            >>> torch.ones(5)
            tensor([ 1.,  1.,  1.,  1.,  1.])

A partir de su documentación, podemos ver que la función ones crea un nuevo tensor con la forma especificada y establece todos los elementos con el valor de 1.

```python
torch.ones(4)
```

    tensor([1., 1., 1., 1.])

En Jupyter Notebook, podemos utilizar el signo "?" para mostrar el documento en otra ventana. Por ejemplo, list? creará un contenido casi idéntico a help(list), mostrándolo en una nueva ventana del navegador. Además, si se utilizan dos signos "??", también se mostrará el código Python que implementa la función.

# Referencia
Dive into Deep Learning. Recuperado de: http://www.d2l.ai/index.html


```python
import sys
sys.stdout.write('hello world\n')
sys.stdout.flush()
for i in range(3):
    sys.stdout.write('%s\n' % i)
```

    hello world
    0
    1
    2

    output to stderr

    some more stdout text

```python

```

```python
    sys.stdout.flush()
sys.stderr.write('output to stderr\n')
sys.stderr.flush()
sys.stdout.write('some more stdout text\n')
sys.stdout.flush()
```

# Markdown Cell

$ e^{ \pm i\theta } = \cos \theta \pm i\sin \theta + \beta $

*It* **really** is!

```python
this is a syntax error
```

```python
print('test')
```

```python
from IPython.display import Latex
Latex('''The mass-energy equivalence is described by the famous equation

$$E=mc^2$$

discovered in 1905 by Albert Einstein.
In natural units ($c$ = 1), the formula expresses the identity

\\begin{equation}
E=m
\\end{equation}''')
```

The mass-energy equivalence is described by the famous equation

$$E=mc^2$$

discovered in 1905 by Albert Einstein.
In natural units ($c$ = 1), the formula expresses the identity

\begin{equation}
E=m
\end{equation}

```python
!pip install numpy ipywidgets bqplot plotly ipympl ipyleaflet ipycytoscape
```

```python
from IPython.display import display
import ipywidgets as w
# s = w.IntSlider(0, 10)£
# s
a = w.IntSlider()
b = w.IntText()
w.jslink((a, 'value'), (b, 'value'))
display(a, b)
```

    IntSlider(value=0)

    IntText(value=0)

```python
import plotly.express as px
df = px.data.tips()
fig = px.histogram(df, x="total_bill", y="tip", color="sex", marginal="rug",
                    hover_data=df.columns)
fig.show()
```

```python

```