## Общая физика. Механика. Лекция 4. Динамика материальной точки II.

*(c) 2021 Alexey Shcherbakov. These lectures are licensed under a Creative Commons Attribution License CC-BY 4.0. All code contained herein is licensed under an MIT license. The lectures were generated from a Jupyter notebook.*

### План лекции:

* силы в макроскопической динамике
* трение в блоках
* баллистическое движение при наличии силы трения
* реактивное движение

### Cилы в макроскопической динамике

В прошлой лекции мы обсуждали, что в природе существует всего четыре фундаментальные взаимодействия. Тем не менее, в практических задачах зачастую использовать фундаментальные силы, соответствующие этим взаимодействиям, неудобно и даже невозможно, так что взаимодействие физических объектов описывают с помощью эффективных сил. Так, например, сила упругости пружины и сила трения в конечном итоге сводятся к электромагнитным взаиомодействиям на микроуровне, но в макроскопических задачах такие микросилы во многих случаях по отдельности учесть невозможно, даже с учетом имеющихся сейчас вычислительных мощностей. Главное требование к таким эффективным силам - адекватное описание состояний физических систем в рамках используемых моделей (в нашем случае - в рамках моделей классической механики). Рассмотрим примеры проявления силы трения.

### Трение в блоках

В качестве примера проявления и применения силы трения рассмотрим задачу о трении в блоке. Пусть блок поворачивает легкую нерастяжимую веревку на угол $\alpha$, причем между веревкой и блоком действует сила трения, а коэффициент трения равен $\mu$. Найдем, как отличаются силы натяжения веревки с двух сторон от блока.

Рассмотрим малый участок веревки, на котором она поворачивается на малый угол $\Delta\alpha$. На участок действуют силы натяжения $T$ и $T+\Delta T$ со стороны остальной части веревки, сила реакции опоры $N$ со стороны блока, и сила трения $F = \mu N$. Спроектируем уравнение движения на ось, касательную к блоку в данной точке, и ось, перпендикулярную блоку, считая веревку неподвижной:

$$F = (T(\alpha) - T(\alpha+\Delta\alpha))\cos(\Delta\alpha/2) = -\Delta T \cos(\Delta\alpha/2)$$
$$N = (T(\alpha+\Delta\alpha) + T(\alpha))\sin(\Delta\alpha/2)$$

Учтем, что угол $\Delta\alpha$ малый, поэтому $\cos(\Delta\alpha/2)\approx 1$, $\sin(\Delta\alpha/2)\approx\Delta\alpha/2$ и $T(\alpha+\Delta\alpha) + T(\alpha) \approx 2T$. Тогда

$$\mu T\Delta\alpha \approx -\Delta T \Rightarrow \frac{\Delta T}{T} \approx -\mu \Delta\alpha$$

Перейдем к бесконечно малым приращениям и проинтегрируем по углу от нуля до какого-то заданного $\alpha_0$:

$$\int\limits_{T_1}^{T_2}\frac{\Delta T}{T} = -\mu \int\limits_{0}^{\alpha_0}\Delta\alpha \Rightarrow \ln\frac{T_2}{T_1} = -\mu\alpha_0 \Rightarrow T_2 = T_1\exp(-\mu\alpha_0)$$

Последнее выражение иногда называется формулой Эйлера.

К выводу формулы Эйлера

### Вязкое трение

При движении в вязкой среде, будь то жидкость или газ, механические объекты испытывают сопротивление. При достаточно медленных движениях сила сопротивления пропорциональна скорости, а при быстрых - квадрату скорости тела. Переход между этими режимами определяется числом Рейнольдса. Физически разница между режимами определяется относительной ролью силы трения между телом и примыкающими к нему слоями вязкой среды и силы динамического сопротивления, возникающей при столкновении движущегося тела с неподвижными массами жидкости или газа. Если преобладает первая сила, то движение называется медленным, если вторая - быстрым.

Рассмотрим пример задачи, где возможно найти аналитическое решение уравнений движения тела в вязкой среде. Пусть снаряд запущен с начальной скоростью $700$ м/с под углом $30^{\circ}$ к горизонту. Будем считать, что сила сопротивления пропорциональна скорости ${\bf F}=-k{\bf v}$. При этом известно, что при падении снаряда с большой высоты его установившаяся скорость равна $v_{\infty} = 200$ м/с.

Сначала рассмотрим свободное падение снаряда. Проекция уравнения движения на вертикальную ось дает:

$$m\frac{dv_y}{dt} = mg - kv_y \Rightarrow \frac{m}{k}\frac{dv_y}{mg/k - v_y} = dt \Rightarrow \frac{m}{k}\int\limits_{0}^{v_y(t)}\dfrac{dv_y}{mg/k - v_y} = \int\limits_{0}^{t}dt $$

$$\Rightarrow v_y(t) = \frac{mg}{k}\left[1-\exp\left(-\frac{k}{m}t\right)\right]$$

Данная в условии установившая скорость - это скорость при $t\rightarrow\infty$. Получаем, что

$$v_{\infty} = \frac{mg}{k}$$

Теперь вернемся к задаче и спроецируем уравнение движения на вертикальную и горизонтальную оси координат:

$$m\dfrac{d{\bf v}}{dt}=m{\bf g}-k{\bf v}\Rightarrow\left\{ \begin{array}{c}
m\dfrac{dv_{x}}{dt}=-kv_{x}\\
m\dfrac{dv_{y}}{dt}=-mg-kv_{y}
\end{array}\right.$$

Эти уравнения можно решать раздельно. Для движения по горизонтали

$$\frac{m}{k}\frac{dv_x}{v_x} = -dt \Rightarrow \frac{m}{k}\int\limits_{v_{x0}}^{v_x}\frac{dv_x}{v_x} = -\int\limits_{0}^{t}dt \Rightarrow \ln \frac{v_x(t)}{v_{x0}} = -\frac{k}{m}t \Rightarrow v_x(t) = v_{x0}\exp\left(-\frac{k}{m}t\right)$$

Зависимость горизонтальной координаты от времени:

$$\frac{dx}{dt} = v_{x0}\exp\left(-\dfrac{k}{m}t\right) \Rightarrow \int\limits_{0}^{x} dx = v_{x0}\int\limits_{0}^{t}\exp\left(-\dfrac{k}{m}t\right)dt \Rightarrow x\left(t\right)=\dfrac{mv_{0x}}{k}\left[1-\exp\left(-\dfrac{k}{m}t\right)\right] $$

Для движения по вертикали

$$m\dfrac{dv_{y}}{dt}=-mg-kv_{y} \Rightarrow \frac{m}{k}\intop_{v_{y0}}^{v_{y}}\dfrac{dv_y}{mg/k+v_y}=-\intop_{0}^{t}dt \Rightarrow v_{y}\left(t\right)=\left(v_{y0} + v_{\infty}\right)\exp\left(-\dfrac{k}{m}t\right)-v_{\infty}$$

Здесь мы заменили полученное выражение для скорости $v_{\infty}$. Зависимость вертикальной координаты от времени:

$$\dfrac{dy}{dt}=\left(v_{y0} + v_{\infty}\right)\exp\left(-\dfrac{k}{m}t\right)-v_{\infty} \Rightarrow$$

$$\Rightarrow \intop_{0}^{y}dy=\intop_{0}^{t}\left[\left(v_{y0}+v_{\infty}\right)\exp\left(-\dfrac{k}{m}t\right)-v_{\infty}\right]dt \Rightarrow y\left(t\right)=\dfrac{m}{k}\left(v_{y0}+v_{\infty}\right)\left[1-\exp\left(-\dfrac{k}{m}t\right)\right] - v_{\infty}t $$

Легко проверить, что при $k\rightarrow 0$ получается известный кинематический результат - движение по параболе.

Найдем максимальную высоту подъема и расстояние по горизонтали до этой точки из условия $v_{y}=0$:

$$\left(v_{y0}+v_{\infty}\right)\exp\left(-\dfrac{k}{m}t\right) = v_{\infty}\Rightarrow t_{H} = \dfrac{m}{k}\ln\left(1 + \dfrac{v_{y0}}{v_{\infty}}\right)$$

$$\Rightarrow H=y\left(t_{H}\right)=\dfrac{m}{k}\left[v_{y0}-v_{\infty}\ln\left(1+\dfrac{v_{y0}}{v_{\infty}}\right)\right]=\dfrac{v_{\infty}^{2}}{g}\left[\dfrac{v_{0}\sin\alpha}{v_{\infty}}-\ln\left(1+\dfrac{v_{0}\sin\alpha}{v_{\infty}}\right)\right]\approx3.01\text{км}$$

Расстояние до точки максимального подъема:

$$x\left(t_{H}\right)=\dfrac{v_{0}^{2}\sin2\alpha}{2g}\left(1+\dfrac{v_{0}\sin\alpha}{v_{\infty}}\right)^{-1}\approx7.87\text{км}$$

```python
# Рассмотрим решение задачи с помощью библиотеки символьных вычислений sympy:

from sympy import *
from sympy.solvers.ode.ode import constantsimp
from sympy.plotting import plot_parametric

## объявление параметров и функций:

# параметры задачи: масса, коэффициент вязкого трения,
#   ускорение свободного падения, время,
#   проекции начальной скорости, модуль начальной скорости и угол выстрела
m, k, g, t, vx0, vy0, v0, ang = symbols('m k g t vx0 vy0 v0 ang', positive=True)
# неизвестные функции времени: координаты и проекции скорости
x, y, vx, vy = symbols('x y vx vy', cls=Function)

## решение уравнений движения:

# уравнение для производной горизонтальной компоненты скорости:
eq_vx = Eq(vx(t).diff(t), ( -(k/m) * vx(t)))
# находим зависимость горизонтальной компоненты скорости от времени:
vx_res = dsolve(eq_vx, ics={vx(0): vx0})
print('зависимость горизонтальной компоненты скорости от времени:')
pprint(vx_res)
# уравнение для производной горизонтальной компоненты координаты:
eq_x = Eq(x(t).diff(t), vx_res.rhs)
# находим зависимость горизонтальной компоненты координаты от времени:
x_res = dsolve(eq_x, ics={x(0): 0})
print('зависимость горизонтальной координаты от времени:')
pprint(x_res)

# уравнение для производной вертикальной компоненты скорости:
eq_vy = Eq(vy(t).diff(t), (-g - (k/m) * vy(t)))
# находим зависимость вертикальной компоненты скорости от времени:
vy_res = dsolve(eq_vy, ics={vy(0): vy0})
print('зависимость вертикальной компоненты скорости от времени:')
pprint(vy_res)
# уравнение для производной вертикальной компоненты координаты:
eq_y = Eq(y(t).diff(t), vy_res.rhs)
# находим зависимость вертикальной компоненты координаты от времени:
y_res = dsolve(eq_y, ics={y(0): 0})
print('зависимость вертикальной координаты от времени:')
pprint(y_res)

## численные результаты:

# численные значения параметров:
ang = 30 # угол выстрела
v0 = 700 # модуль начальной скорости снаряда
substitution = [(m, 1), (k, 0.049), (g, 9.8), (vx0, v0*cos(ang*pi/180)), (vy0, v0*sin(ang*pi/180))]

# аналитические и численные результаты без учета сопротивления воздуха:
x_res_free = vx0*t
y_res_free = vy0*t - 0.5*g*t**2
x_fun_free = x_res_free.subs(substitution)
y_fun_free = y_res_free.subs(substitution)
    # время полета:
ft_max_free = 2*vy0/g
t_max_free = ft_max_free.subs(substitution);
print('время полета без учета сопротивления воздуха: %.2fс' % t_max_free)
    # максимальная высота:
h_max_free = 0.001*y_fun_free.evalf().subs(t, 0.5*t_max_free)
print('максимальная высота без учета сопротивления воздуха: %.2f км' % h_max_free)
    # дальность полета:
l_max_free = 0.001*x_fun_free.evalf().subs(t, t_max_free)
print('дальность полета без учета сопротивления воздуха: %.2f км' % l_max_free)

# аналитические и численные результаты с учетом сопротивления воздуха:
x_fun = x_res.rhs.subs(substitution)
y_fun = y_res.rhs.subs(substitution)
    # найдем время полета через условие x(t)=0 с начальным условием, заданным t_max_free:
t_max = nsolve(y_fun, t, t_max_free)
print('время полета с учетом сопротивления воздуха: %.2fс' % t_max)
    # найдем время полета до максимальной высоты через условие vy(t)=0:
t_max_half = nsolve(vy_res.rhs.subs(substitution), t, 0.5*t_max_free)
print('время полета до максимальной высоты с учетом сопротивления воздуха: %.2fс' % t_max_half)
    # максимальная высота:
h_max = 0.001*y_fun.evalf().subs(t, t_max_half)
print('максимальная высота с учетом сопротивления воздуха: %.2f км' % h_max)
    # дальность полета:
l_max = 0.001*x_fun.evalf().subs(t, t_max)
print('дальность полета с учетом сопротивления воздуха: %.2f км' % l_max)

## графики:
import matplotlib.pyplot as plt
# построим траекторию без учета сопротивления воздуха (красная) и с учетом сопротивления (синяя):
p = plot_parametric((x_fun, y_fun, (t, 0, t_max)),
                    (x_fun_free, y_fun_free, (t, 0, t_max_free)),
                    show=False)
x1,y1 = p[0].get_points()
x2,y2 = p[1].get_points()
plt.plot(0.001*x1, 0.001*y1, label = 'с сопротивлением воздуха')
plt.plot(0.001*x2, 0.001*y2, label = 'без сопротивления воздуха')
plt.legend()
plt.xlim(0)
plt.ylim(0)
plt.xlabel('x, км')
plt.ylabel('y, км')
plt.show()
```

### Реактивное движение

Рассмотрим движение с переменной массой – движение при котором часть масса рассматриваемого механического объекта изменяется, что приводит к изменению импульса этого объекта. Частным случаем является реактивное движением, при котором масса ракеты выбрасывается для ускорения оставшейся части. Для краткости рассмотрим ракету и вылетающий из нее газ, и запишем изменение импульса системы «ракета – выхлопные газы» за малый промежуток времени $dt$:

$$\left(m+dm\right)\left({\bf v}+d{\bf v}\right)+dm_{gas}{\bf v}_{gas}-m{\bf v}={\bf F}dt$$

Раскроем скобки и пренебрежем малой величиной второго порядка $dmd{\bf v}$, а также введем скорость вылета газов относительно ракеты ${\bf u}={\bf v}_{gas}-{\bf v}$. В силу закона сохранения массы $m+m_{gas}=const\Rightarrow dm=-dm_{gas}$. Тогда

$$md{\bf v}+dm_{gas}{\bf u}_{gas}={\bf F}dt\Rightarrow m\dfrac{d{\bf v}}{dt}=\dfrac{dm}{dt}{\bf u}+{\bf F}$$

Это уравнение называется уравнением Мещерского. Оно показывает, что, кроме внешней силы, на тело действует эффективная реактивная сила $\dfrac{dm}{dt}{\bf u}$.

К выводу уравнения Мещерского

Теперь для простоты предположим, что ракета движется прямолинейно, и на нее не действуют внешние силы. Поскольку скорость газа направлена в противоположном направлении движению ракеты, то проекция уравнения на направление движения будет

$$m\dfrac{dv}{dt}=-\dfrac{dm}{dt}u\Rightarrow dv=-u\dfrac{dm}{m}\Rightarrow\intop_{v_{1}}^{v_{2}}dv=-u\intop_{m_{1}}^{m_{2}}\dfrac{dm}{m}\Rightarrow\dfrac{v_{2}-v_{1}}{u}=\ln\dfrac{m_{1}}{m_{2}}$$

Последнее выражение называется формулой Циолковского.

Рассмотрим пример задачи с переменной массой: две одинаковые легкие тележки, на которых сидят два одинаковых дворника, катятся по инерции прямолинейно параллельно друг другу с одинаковыми скоростями по очень скользкому льду. Начинает падать снег. Дворник, сидящий на одной из тележек, сбрасывает падающий снег с тележки в стороны перпендикулярно движению, а дворник на второй тележке спит. Какая из тележек быстрее проедет одно и то же расстояние?

Если рассуждать качественно, можно приблизительно записать закон сохранения импульса в проекции на направление движения тележек: $mv_{1}=\left(m+\Delta m\right)v_{2}$. Видно, что уменьшение скорости определяется отношением $m/\left(m+\Delta m\right)$. Для тележки, с которой сметается снег, оно постоянно, а для тележки, на которой накапливается снег, оно уменьшается. Поэтому вторая будет тормозиться медленнее. Чтобы решить задачу точнее, запишем уравнение движения (воспользуемся уравнение Мещерского):

$$m\dfrac{dv}{dt}=-v\dfrac{dm}{dt}$$

В случае спящего дворника:

$$m\dfrac{dv}{dt}=-\mu v\Rightarrow\dfrac{dv}{v}=-\mu\dfrac{dt}{m_{0}+\mu t}\Rightarrow\ln\dfrac{v\left(t\right)}{v_{0}}=-\ln\dfrac{m_{0}+\mu t}{m_{0}}\Rightarrow v\left(t\right)=v_{0}\dfrac{m_{0}}{m_{0}+\mu t}$$

В случае работающего дворника:

$$m_{0}\dfrac{dv}{dt}=-\mu v\Rightarrow\dfrac{dv}{v}=-\dfrac{\mu}{m}dt\Rightarrow v=v_{0}\exp\left(-\dfrac{\mu}{m}dt\right)$$

Экспоненциальная функция убывает быстрее стеменной, что подтверждает сделанное качественное предположение.


```python
%load_ext autoreload
%autoreload 2

%matplotlib inline
```

```python
#export
from exp.nb_06 import *
```

## ConvNet

Let's get the data and training interface from where we left in the last notebook.

[Jump_to lesson 10 video](https://course.fast.ai/videos/?lesson=10&t=5899)

```python
x_train,y_train,x_valid,y_valid = get_data()

x_train,x_valid = normalize_to(x_train,x_valid)
train_ds,valid_ds = Dataset(x_train, y_train),Dataset(x_valid, y_valid)

nh,bs = 50,512
c = y_train.max().item()+1
loss_func = F.cross_entropy

data = DataBunch(*get_dls(train_ds, valid_ds, bs), c)
```

```python
mnist_view = view_tfm(1,28,28)
cbfs = [Recorder,
        partial(AvgStatsCallback,accuracy),
        CudaCallback,
        partial(BatchTransformXCallback, mnist_view)]
```

```python
nfs = [8,16,32,64,64]
```

```python
learn,run = get_learn_run(nfs, data, 0.4, conv_layer, cbs=cbfs)
```

```python
%time run.fit(2, learn)
```

    train: [1.0102521875, tensor(0.6626, device='cuda:0')]
    valid: [0.1812515869140625, tensor(0.9451, device='cuda:0')]
    train: [0.17909103515625, tensor(0.9446, device='cuda:0')]
    valid: [0.11803392333984375, tensor(0.9650, device='cuda:0')]
    CPU times: user 5.88 s, sys: 1.08 s, total: 6.97 s
    Wall time: 4.72 s

## Batchnorm

### Custom

Let's start by building our own `BatchNorm` layer from scratch.

[Jump_to lesson 10 video](https://course.fast.ai/videos/?lesson=10&t=6018)

```python
class BatchNorm(nn.Module):
    def __init__(self, nf, mom=0.1, eps=1e-5):
        super().__init__()
        # NB: pytorch bn mom is opposite of what you'd expect
        self.mom,self.eps = mom,eps
        self.mults = nn.Parameter(torch.ones (nf,1,1))
        self.adds  = nn.Parameter(torch.zeros(nf,1,1))
        self.register_buffer('vars',  torch.ones(1,nf,1,1))
        self.register_buffer('means', torch.zeros(1,nf,1,1))

    def update_stats(self, x):
        m = x.mean((0,2,3), keepdim=True)
        v = x.var ((0,2,3), keepdim=True)
        self.means.lerp_(m, self.mom)
        self.vars.lerp_ (v, self.mom)
        return m,v

    def forward(self, x):
        if self.training:
            with torch.no_grad(): m,v = self.update_stats(x)
        else: m,v = self.means,self.vars
        x = (x-m) / (v+self.eps).sqrt()
        return x*self.mults + self.adds
```

```python
def conv_layer(ni, nf, ks=3, stride=2, bn=True, **kwargs):
    # No bias needed if using bn
    layers = [nn.Conv2d(ni, nf, ks, padding=ks//2, stride=stride, bias=not bn),
              GeneralRelu(**kwargs)]
    if bn: layers.append(BatchNorm(nf))
    return nn.Sequential(*layers)
```

```python
#export
def init_cnn_(m, f):
    if isinstance(m, nn.Conv2d):
        f(m.weight, a=0.1)
        if getattr(m, 'bias', None) is not None: m.bias.data.zero_()
    for l in m.children(): init_cnn_(l, f)

def init_cnn(m, uniform=False):
    f = init.kaiming_uniform_ if uniform else init.kaiming_normal_
    init_cnn_(m, f)

def get_learn_run(nfs, data, lr, layer, cbs=None, opt_func=None, uniform=False, **kwargs):
    model = get_cnn_model(data, nfs, layer, **kwargs)
    init_cnn(model, uniform=uniform)
    return get_runner(model, data, lr=lr, cbs=cbs, opt_func=opt_func)
```

We can then use it in training and see how it helps keep the activations means to 0 and the std to 1.

```python
learn,run = get_learn_run(nfs, data, 0.9, conv_layer, cbs=cbfs)
```

```python
with Hooks(learn.model, append_stats) as hooks:
    run.fit(1, learn)
    fig,(ax0,ax1) = plt.subplots(1,2, figsize=(10,4))
    for h in hooks[:-1]:
        ms,ss = h.stats
        ax0.plot(ms[:10])
        ax1.plot(ss[:10])
        h.remove()
    plt.legend(range(6));

    fig,(ax0,ax1) = plt.subplots(1,2, figsize=(10,4))
    for h in hooks[:-1]:
        ms,ss = h.stats
        ax0.plot(ms)
        ax1.plot(ss)
```

```python
learn,run = get_learn_run(nfs, data, 1.0, conv_layer, cbs=cbfs)
```

```python
%time run.fit(3, learn)
```

    train: [0.2791133203125, tensor(0.9127, device='cuda:0')]
    valid: [0.15584984130859375, tensor(0.9505, device='cuda:0')]
    train: [0.0855846875, tensor(0.9738, device='cuda:0')]
    valid: [0.09478010864257813, tensor(0.9711, device='cuda:0')]
    train: [0.06063310546875, tensor(0.9815, device='cuda:0')]
    valid: [0.0886656005859375, tensor(0.9730, device='cuda:0')]
    CPU times: user 5.46 s, sys: 461 ms, total: 5.92 s
    Wall time: 2.97 s

### Builtin batchnorm

[Jump_to lesson 10 video](https://course.fast.ai/videos/?lesson=10&t=6679)

```python
#export
def conv_layer(ni, nf, ks=3, stride=2, bn=True, **kwargs):
    layers = [nn.Conv2d(ni, nf, ks, padding=ks//2, stride=stride, bias=not bn),
              GeneralRelu(**kwargs)]
    if bn: layers.append(nn.BatchNorm2d(nf, eps=1e-5, momentum=0.1))
    return nn.Sequential(*layers)
```

```python
learn,run = get_learn_run(nfs, data, 1., conv_layer, cbs=cbfs)
```

```python
%time run.fit(3, learn)
```

    train: [0.225350703125, tensor(0.9291, device='cuda:0')]
    valid: [0.09983468627929687, tensor(0.9671, device='cuda:0')]
    train: [0.0640519287109375, tensor(0.9802, device='cuda:0')]
    valid: [0.07516433715820313, tensor(0.9770, device='cuda:0')]
    train: [0.042770673828125, tensor(0.9866, device='cuda:0')]
    valid: [0.07645261840820312, tensor(0.9763, device='cuda:0')]
    CPU times: user 5.23 s, sys: 393 ms, total: 5.62 s
    Wall time: 2.82 s

### With scheduler

Now let's add the usual warm-up/annealing.

```python
sched = combine_scheds([0.3, 0.7], [sched_lin(0.6, 2.), sched_lin(2., 0.1)])
```

```python
learn,run = get_learn_run(nfs, data, 0.9, conv_layer, cbs=cbfs
                          +[partial(ParamScheduler,'lr', sched)])
```

```python
run.fit(8, learn)
```

    train: [0.24032658203125, tensor(0.9274, device='cuda:0')]
    valid: [0.121382470703125, tensor(0.9611, device='cuda:0')]
    train: [0.0779952880859375, tensor(0.9753, device='cuda:0')]
    valid: [0.08348574829101563, tensor(0.9759, device='cuda:0')]
    train: [0.05832263671875, tensor(0.9819, device='cuda:0')]
    valid: [0.07855689697265625, tensor(0.9761, device='cuda:0')]
    train: [0.0339348095703125, tensor(0.9893, device='cuda:0')]
    valid: [0.05644942626953125, tensor(0.9843, device='cuda:0')]
    train: [0.0203065771484375, tensor(0.9935, device='cuda:0')]
    valid: [0.04931312866210937, tensor(0.9864, device='cuda:0')]
    train: [0.01215261962890625, tensor(0.9966, device='cuda:0')]
    valid: [0.04616991882324219, tensor(0.9873, device='cuda:0')]
    train: [0.00690358642578125, tensor(0.9986, device='cuda:0')]
    valid: [0.04404058837890625, tensor(0.9883, device='cuda:0')]
    train: [0.004632399291992187, tensor(0.9992, device='cuda:0')]
    valid: [0.044213510131835936, tensor(0.9882, device='cuda:0')]

## More norms

### Layer norm

From [the paper](https://arxiv.org/abs/1607.06450): "*batch normalization cannot be applied to online learning tasks or to extremely large distributed models where the minibatches have to be small*".

General equation for a norm layer with learnable affine:

$$y = \frac{x - \mathrm{E}[x]}{ \sqrt{\mathrm{Var}[x] + \epsilon}} * \gamma + \beta$$

The difference with BatchNorm is
1. we don't keep a moving average
2. we don't average over the batches dimension but over the hidden dimension, so it's independent of the batch size

[Jump_to lesson 10 video](https://course.fast.ai/videos/?lesson=10&t=6717)

```python
class LayerNorm(nn.Module):
    __constants__ = ['eps']
    def __init__(self, eps=1e-5):
        super().__init__()
        self.eps = eps
        self.mult = nn.Parameter(tensor(1.))
        self.add  = nn.Parameter(tensor(0.))

    def forward(self, x):
        m = x.mean((1,2,3), keepdim=True)
        v = x.var ((1,2,3), keepdim=True)
        x = (x-m) / ((v+self.eps).sqrt())
        return x*self.mult + self.add
```

```python
def conv_ln(ni, nf, ks=3, stride=2, bn=True, **kwargs):
    layers = [nn.Conv2d(ni, nf, ks, padding=ks//2, stride=stride, bias=True),
              GeneralRelu(**kwargs)]
    if bn: layers.append(LayerNorm())
    return nn.Sequential(*layers)
```

```python
learn,run = get_learn_run(nfs, data, 0.8, conv_ln, cbs=cbfs)
```

```python
%time run.fit(3, learn)
```

    train: [nan, tensor(0.1474, device='cuda:0')]
    valid: [nan, tensor(0.0991, device='cuda:0')]
    train: [nan, tensor(0.0986, device='cuda:0')]
    valid: [nan, tensor(0.0991, device='cuda:0')]
    train: [nan, tensor(0.0986, device='cuda:0')]
    valid: [nan, tensor(0.0991, device='cuda:0')]
    CPU times: user 6.04 s, sys: 489 ms, total: 6.52 s
    Wall time: 3.27 s

*Thought experiment*: can this distinguish foggy days from sunny days (assuming you're using it before the first conv)?

### Instance norm

From [the paper](https://arxiv.org/abs/1607.08022):

The key difference between **contrast** and batch normalization is that the latter applies the normalization to a  whole batch of images instead for single ones:

\begin{equation}\label{eq:bnorm}
    y_{tijk} =  \frac{x_{tijk} - \mu_{i}}{\sqrt{\sigma_i^2 + \epsilon}},
    \quad
    \mu_i = \frac{1}{HWT}\sum_{t=1}^T\sum_{l=1}^W \sum_{m=1}^H x_{tilm},
    \quad
    \sigma_i^2 = \frac{1}{HWT}\sum_{t=1}^T\sum_{l=1}^W \sum_{m=1}^H (x_{tilm} - mu_i)^2.
\end{equation}

In order to combine the effects of instance-specific normalization and batch normalization, we propose to replace the latter by the *instance normalization* (also known as *contrast normalization*) layer:

\begin{equation}\label{eq:inorm}
    y_{tijk} =  \frac{x_{tijk} - \mu_{ti}}{\sqrt{\sigma_{ti}^2 + \epsilon}},
    \quad
    \mu_{ti} = \frac{1}{HW}\sum_{l=1}^W \sum_{m=1}^H x_{tilm},
    \quad
    \sigma_{ti}^2 = \frac{1}{HW}\sum_{l=1}^W \sum_{m=1}^H (x_{tilm} - mu_{ti})^2.
\end{equation}

[Jump_to lesson 10 video](https://course.fast.ai/videos/?lesson=10&t=7114)

```python
class InstanceNorm(nn.Module):
    __constants__ = ['eps']
    def __init__(self, nf, eps=1e-0):
        super().__init__()
        self.eps = eps
        self.mults = nn.Parameter(torch.ones (nf,1,1))
        self.adds  = nn.Parameter(torch.zeros(nf,1,1))

    def forward(self, x):
        m = x.mean((2,3), keepdim=True)
        v = x.var ((2,3), keepdim=True)
        res = (x-m) / ((v+self.eps).sqrt())
        return res*self.mults + self.adds
```

```python
def conv_in(ni, nf, ks=3, stride=2, bn=True, **kwargs):
    layers = [nn.Conv2d(ni, nf, ks, padding=ks//2, stride=stride, bias=True),
              GeneralRelu(**kwargs)]
    if bn: layers.append(InstanceNorm(nf))
    return nn.Sequential(*layers)
```

```python
learn,run = get_learn_run(nfs, data, 0.1, conv_in, cbs=cbfs)
```

```python
%time run.fit(3, learn)
```

    train: [nan, tensor(0.0986, device='cuda:0')]
    valid: [nan, tensor(0.0991, device='cuda:0')]
    train: [nan, tensor(0.0986, device='cuda:0')]
    valid: [nan, tensor(0.0991, device='cuda:0')]
    train: [nan, tensor(0.0986, device='cuda:0')]
    valid: [nan, tensor(0.0991, device='cuda:0')]
    CPU times: user 6.23 s, sys: 412 ms, total: 6.64 s
    Wall time: 3.33 s

*Question*: why can't this classify anything?

Lost in all those norms? The authors from the [group norm paper](https://arxiv.org/pdf/1803.08494.pdf) have you covered:

### Group norm

[Jump_to lesson 10 video](https://course.fast.ai/videos/?lesson=10&t=7213)

*From the PyTorch docs:*

`GroupNorm(num_groups, num_channels, eps=1e-5, affine=True)`

The input channels are separated into `num_groups` groups, each containing
``num_channels / num_groups`` channels. The mean and standard-deviation are calculated
separately over the each group. $\gamma$ and $\beta$ are learnable
per-channel affine transform parameter vectorss of size `num_channels` if
`affine` is ``True``.

This layer uses statistics computed from input data in both training and
evaluation modes.

Args:
-    num_groups (int): number of groups to separate the channels into
-    num_channels (int): number of channels expected in input
-    eps: a value added to the denominator for numerical stability. Default: 1e-5
-    affine: a boolean value that when set to ``True``, this module
        has learnable per-channel affine parameters initialized to ones (for weights)
        and zeros (for biases). Default: ``True``.

Shape:
- Input: `(N, num_channels, *)`
- Output: `(N, num_channels, *)` (same shape as input)

Examples::

    >>> input = torch.randn(20, 6, 10, 10)
    >>> # Separate 6 channels into 3 groups
    >>> m = nn.GroupNorm(3, 6)
    >>> # Separate 6 channels into 6 groups (equivalent with InstanceNorm)
    >>> m = nn.GroupNorm(6, 6)
    >>> # Put all 6 channels into a single group (equivalent with LayerNorm)
    >>> m = nn.GroupNorm(1, 6)
    >>> # Activating the module
    >>> output = m(input)

## Fix small batch sizes

### What's the problem?

When we compute the statistics (mean and std) for a BatchNorm Layer on a small batch, it is possible that we get a standard deviation very close to 0. because there aren't many samples (the variance of one thing is 0. since it's equal to its mean).

[Jump_to lesson 10 video](https://course.fast.ai/videos/?lesson=10&t=7304)

```python
data = DataBunch(*get_dls(train_ds, valid_ds, 2), c)
```

```python
def conv_layer(ni, nf, ks=3, stride=2, bn=True, **kwargs):
    layers = [nn.Conv2d(ni, nf, ks, padding=ks//2, stride=stride, bias=not bn),
              GeneralRelu(**kwargs)]
    if bn: layers.append(nn.BatchNorm2d(nf, eps=1e-5, momentum=0.1))
    return nn.Sequential(*layers)
```

```python
learn,run = get_learn_run(nfs, data, 0.4, conv_layer, cbs=cbfs)
```

```python
%time run.fit(1, learn)
```

    train: [2.35726609375, tensor(0.1654, device='cuda:0')]
    valid: [419772.8768, tensor(0.2312, device='cuda:0')]
    CPU times: user 57.1 s, sys: 659 ms, total: 57.8 s
    Wall time: 58.1 s

### Running Batch Norm

To solve this problem we introduce a Running BatchNorm that uses smoother running mean and variance for the mean and std.

[Jump_to lesson 10 video](https://course.fast.ai/videos/?lesson=10&t=7516)

```python
class RunningBatchNorm(nn.Module):
    def __init__(self, nf, mom=0.1, eps=1e-5):
        super().__init__()
        self.mom,self.eps = mom,eps
        self.mults = nn.Parameter(torch.ones (nf,1,1))
        self.adds = nn.Parameter(torch.zeros(nf,1,1))
        self.register_buffer('sums', torch.zeros(1,nf,1,1))
        self.register_buffer('sqrs', torch.zeros(1,nf,1,1))
        self.register_buffer('batch', tensor(0.))
        self.register_buffer('count', tensor(0.))
        self.register_buffer('step', tensor(0.))
        self.register_buffer('dbias', tensor(0.))

    def update_stats(self, x):
        bs,nc,*_ = x.shape
        self.sums.detach_()
        self.sqrs.detach_()
        dims = (0,2,3)
        s = x.sum(dims, keepdim=True)
        ss = (x*x).sum(dims, keepdim=True)
        c = self.count.new_tensor(x.numel()/nc)
        mom1 = 1 - (1-self.mom)/math.sqrt(bs-1)
        self.mom1 = self.dbias.new_tensor(mom1)
        self.sums.lerp_(s, self.mom1)
        self.sqrs.lerp_(ss, self.mom1)
        self.count.lerp_(c, self.mom1)
        self.dbias = self.dbias*(1-self.mom1) + self.mom1
        self.batch += bs
        self.step += 1

    def forward(self, x):
        if self.training: self.update_stats(x)
        sums = self.sums
        sqrs = self.sqrs
        c = self.count
        if self.step<100:
            sums = sums / self.dbias
            sqrs = sqrs / self.dbias
            c    = c    / self.dbias
        means = sums/c
        vars = (sqrs/c).sub_(means*means)
        if bool(self.batch < 20): vars.clamp_min_(0.01)
        x = (x-means).div_((vars.add_(self.eps)).sqrt())
        return x.mul_(self.mults).add_(self.adds)
```

```python
def conv_rbn(ni, nf, ks=3, stride=2, bn=True, **kwargs):
    layers = [nn.Conv2d(ni, nf, ks, padding=ks//2, stride=stride, bias=not bn),
              GeneralRelu(**kwargs)]
    if bn: layers.append(RunningBatchNorm(nf))
    return nn.Sequential(*layers)
```

```python
learn,run = get_learn_run(nfs, data, 0.4, conv_rbn, cbs=cbfs)
```

```python
%time run.fit(1, learn)
```

    train: [0.3598825, tensor(0.9052, device='cuda:0')]
    valid: [0.114168505859375, tensor(0.9704, device='cuda:0')]
    CPU times: user 2min 26s, sys: 1.13 s, total: 2min 27s
    Wall time: 2min 29s

This solves the small batch size issue!

### What can we do in a single epoch?

Now let's see with a decent batch size what result we can get.

[Jump_to lesson 10 video](https://course.fast.ai/videos/?lesson=10&t=8068)

```python
data = DataBunch(*get_dls(train_ds, valid_ds, 32), c)
```

```python
learn,run = get_learn_run(nfs, data, 0.9, conv_rbn, cbs=cbfs
                          +[partial(ParamScheduler,'lr', sched_lin(1., 0.2))])
```

```python
%time run.fit(1, learn)
```

    train: [0.1526426171875, tensor(0.9537, device='cuda:0')]
    valid: [0.07270771484375, tensor(0.9790, device='cuda:0')]
    CPU times: user 10 s, sys: 107 ms, total: 10.1 s
    Wall time: 9.76 s

## Export

```python
nb_auto_export()
```

    <IPython.core.display.Javascript object>

```python

```


```python
%matplotlib inline
%config InlineBackend.figure_format ='retina'

import torch
import socialforce

_ = torch.manual_seed(42)
```

# 1+1D

## Parametric

The potential $V(b, d_{\perp})$ is approximated by two 1D potentials:
\begin{align}
    V(b, d_{\perp}) &= \textrm{SF}(b) \cdot \max(0, 1 + a d_{\perp})
\end{align}
where $a$ is the `asymmetry` parameter of the `PedPedPotential2D` constructor.

```python
V = socialforce.potentials.PedPedPotential2D(asymmetry=-1.0)
with socialforce.show.canvas(figsize=(12, 6), ncols=2) as (ax1, ax2):
    socialforce.show.potential_2d(V, ax1)
    socialforce.show.potential_2d_grad(V, ax2)
```

## Scenarios

Here we use a combination of synthetic {ref}`Circle and ParallelOvertake scenarios <scenarios>`.

```python
circle = socialforce.scenarios.Circle(ped_ped=V)
parallel = socialforce.scenarios.ParallelOvertake(ped_ped=V)
scenarios = circle.generate(5) + parallel.generate(5)
true_experience = socialforce.Trainer.scenes_to_experience(scenarios)
```

## MLP

Next we create a model for pedestrian-pedestrian interaction that is the
product of two 1D potentials: one potential as a function of $b$ and another
as a function of perpendicular distance. The potential is initialized to random
weights and biases.

\begin{align}
    V(b, d_{\perp}) &= \textrm{MLP}_b(b) \cdot \textrm{MLP}_{\perp}(d_{\perp}) \;\;\; .
\end{align}

```python
V = socialforce.potentials.PedPedPotentialMLP1p1D()
with socialforce.show.canvas(figsize=(12, 6), ncols=2) as (ax1, ax2):
    socialforce.show.potential_2d(V, ax1)
    socialforce.show.potential_2d_grad(V, ax2)
```

## Inference

Next, we use the standard SGD optimizer from PyTorch and train the ped-ped
interaction model on the synthetic data created above.

```python
simulator = socialforce.Simulator(ped_ped=V)
opt = torch.optim.SGD(V.parameters(), lr=1.0)
socialforce.Trainer(simulator, opt).loop(20, true_experience, log_interval=5)
```

```python
with socialforce.show.canvas(figsize=(12, 6), ncols=2) as (ax1, ax2):
    socialforce.show.potential_2d(V, ax1)
    socialforce.show.potential_2d_grad(V, ax2)
```

```python

```


# Customer Segmentation: Estimate Individualized Responses to Incentives

Nowadays, business decision makers rely on estimating the causal effect of interventions to answer what-if questions about shifts in strategy, such as promoting specific product with discount, adding new features to a website or increasing investment from a sales team. However, rather than learning whether to take action for a specific intervention for all users, people are increasingly interested in understanding the different responses from different users to the two alternatives. Identifying the characteristics of users having the strongest response for the intervention could help make rules to segment the future users into different groups. This can help optimize the policy to use the least resources and get the most profit.

In this case study, we will use a personalized pricing example to explain how the [EconML](https://aka.ms/econml) and [DoWhy](https://github.com/microsoft/dowhy) libraries could fit into this problem and provide robust and reliable causal solutions.

### Summary

1. [Background](#background)
2. [Data](#data)
3. [Create Causal Model and Identify Causal Effect with DoWhy](#identify)
4. [Get Causal Effects with EconML](#estimate)
5. [Test Estimate Robustness with DoWhy](#robustness)
    1. [Add Random Common Cause](#random-common-cause)
    2. [Add Unobserved Common Cause](#unobserved-common-cause)
    3. [Replace Treatment with a Random (Placebo) Variable](#placebo-variable)
    4. [Remove a Random Subset of the Data](#subset)
6. [Understand Treatment Effects with EconML](#interpret)
7. [Make Policy Decisions with EconML](#policy)
8. [Conclusions](#conclusion)

# Background

The global online media market is growing fast over the years. Media companies are always interested in attracting more users into the market and encouraging them to buy more songs or become members. In this example, we'll consider a scenario where one experiment a media company is running is to give small discount (10%, 20% or 0) to their current users based on their income level in order to boost the likelihood of their purchase. The goal is to understand the **heterogeneous price elasticity of demand** for people with different income level, learning which users would respond most strongly to a small discount. Furthermore, their end goal is to make sure that despite decreasing the price for some consumers, the demand is raised enough to boost the overall revenue.

The EconML and DoWhy libraries complement each other in implementing this solution. On one hand, the DoWhy library can help [build a causal model, indentify the causal effect](#identify) and [test causal assumptions](#robustness). On the other hand, EconML’s `DML` based estimators can be used to take the discount variation in existing data, along with a rich set of user features, to [estimate heterogeneous price sensitivities](#estimate) that vary with multiple customer features. Then, the `SingleTreeCateInterpreter` provides a [presentation-ready summary](#interpret) of the key features that explain the biggest differences in responsiveness to a discount, and the `SingleTreePolicyInterpreter` recommends a [policy](#policy) on who should receive a discount in order to increase revenue (not only demand), which could help the company to set an optimal price for those users in the future.

```python
# Some imports to get us started
import warnings
warnings.simplefilter('ignore')

# Utilities
import os
import urllib.request
import numpy as np
import pandas as pd
from networkx.drawing.nx_pydot import to_pydot
from IPython.display import Image, display

# Generic ML imports
from sklearn.preprocessing import PolynomialFeatures
from sklearn.ensemble import GradientBoostingRegressor

# EconML imports
from econml.dml import LinearDML, CausalForestDML
from econml.cate_interpreter import SingleTreeCateInterpreter, SingleTreePolicyInterpreter

import matplotlib.pyplot as plt

%matplotlib inline
```

# Data

The dataset* has ~10,000 observations and includes 9 continuous and categorical variables that represent user's characteristics and online behaviour history such as age, log income, previous purchase, previous online time per week, etc.

We define the following variables:

Feature Name|Type|Details
:--- |:---|:---
**account_age** |W| user's account age
**age** |W|user's age
**avg_hours** |W| the average hours user was online per week in the past
**days_visited** |W| the average number of days user visited the website per week in the past
**friend_count** |W| number of friends user connected in the account
**has_membership** |W| whether the user had membership
**is_US** |W| whether the user accesses the website from the US
**songs_purchased** |W| the average songs user purchased per week in the past
**income** |X| user's income
**price** |T| the price user was exposed during the discount season (baseline price * small discount)
**demand** |Y| songs user purchased during the discount season

**To protect the privacy of the company, we use the simulated data as an example here. The data is synthetically generated and the feature distributions don't correspond to real distributions. However, the feature names have preserved their names and meaning.*

The treatment and outcome are generated using the following functions:
$$
T =
\begin{cases}
  1 & \text{with } p=0.2,  \\
  0.9 & \text{with }p=0.3, & \text{if income}<1 \\
  0.8 & \text{with }p=0.5, \\
  \\
    1 & \text{with }p=0.7, \\
  0.9 & \text{with }p=0.2, & \text{if income}\ge1 \\
  0.8 & \text{with }p=0.1, \\
\end{cases}
$$

\begin{align}
\gamma(X) & = -3 - 14 \cdot \{\text{income}<1\} \\
\beta(X,W) & = 20 + 0.5 \cdot \text{avg_hours} + 5 \cdot \{\text{days_visited}>4\} \\
Y &= \gamma(X) \cdot T + \beta(X,W)
\end{align}

```python
# Import the sample pricing data
file_url = "https://msalicedatapublic.blob.core.windows.net/datasets/Pricing/pricing_sample.csv"
train_data = pd.read_csv(file_url)
```

```python
# Data sample
train_data.head()
```

      account_age
      age
      avg_hours
      days_visited
      friends_count
      has_membership
      is_US
      songs_purchased
      income
      price
      demand

      0
      3
      53
      1.834234
      2
      8
      1
      1
      4.903237
      0.960863
      1.0
      3.917117

      1
      5
      54
      7.171411
      7
      9
      0
      1
      3.330161
      0.732487
      1.0
      11.585706

      2
      3
      33
      5.351920
      6
      9
      0
      1
      3.036203
      1.130937
      1.0
      24.675960

      3
      2
      34
      6.723551
      0
      8
      0
      1
      7.911926
      0.929197
      1.0
      6.361776

      4
      4
      30
      2.448247
      5
      8
      1
      0
      7.148967
      0.533527
      0.8
      12.624123

```python
# Define estimator inputs
train_data["log_demand"] = np.log(train_data["demand"])
train_data["log_price"] = np.log(train_data["price"])

Y = train_data["log_demand"].values
T = train_data["log_price"].values
X = train_data[["income"]].values  # features
confounder_names = ["account_age", "age", "avg_hours", "days_visited", "friends_count", "has_membership", "is_US", "songs_purchased"]
W = train_data[confounder_names].values
```

```python
# Get test data
X_test = np.linspace(0, 5, 100).reshape(-1, 1)
X_test_data = pd.DataFrame(X_test, columns=["income"])
```

# Create Causal Model and Identify Causal Effect with DoWhy

We define the causal assumptions with DoWhy. For example, we can include features we believe as confounders and features we think will influence the heterogeneity of the effect. With these assumptions defined, DoWhy can generate a causal graph for us, and use that graph to first identify the causal effect.

```python
# initiate an EconML cate estimator
est = LinearDML(model_y=GradientBoostingRegressor(), model_t=GradientBoostingRegressor(),
              featurizer=PolynomialFeatures(degree=2, include_bias=False))
```

```python
# fit through dowhy
est_dw = est.dowhy.fit(Y, T, X=X, W=W, outcome_names=["log_demand"], treatment_names=["log_price"], feature_names=["income"],
               confounder_names=confounder_names, inference="statsmodels")
```

    WARNING:dowhy.causal_model:Causal Graph not provided. DoWhy will construct a graph based on data inputs.
    INFO:dowhy.causal_graph:If this is observed data (not from a randomized experiment), there might always be missing confounders. Adding a node named "Unobserved Confounders" to reflect this.
    INFO:dowhy.causal_model:Model to find the causal effect of treatment ['log_price'] on outcome ['log_demand']
    WARNING:dowhy.causal_identifier:If this is observed data (not from a randomized experiment), there might always be missing confounders. Causal effect cannot be identified perfectly.
    INFO:dowhy.causal_identifier:Continuing by ignoring these unobserved confounders because proceed_when_unidentifiable flag is True.
    INFO:dowhy.causal_identifier:Instrumental variables for treatment and outcome:[]
    INFO:dowhy.causal_identifier:Frontdoor variables for treatment and outcome:[]
    INFO:dowhy.causal_estimator:INFO: Using EconML Estimator
    INFO:dowhy.causal_estimator:b: log_demand~log_price+has_membership+is_US+friends_count+age+income+days_visited+songs_purchased+account_age+avg_hours | income

```python
# Visualize causal graph
try:
    # Try pretty printing the graph. Requires pydot and pygraphviz
    display(
        Image(to_pydot(est_dw._graph._graph).create_png())
    )
except:
    # Fall back on default graph view
    est_dw.view_model()
```

```python
identified_estimand = est_dw.identified_estimand_
print(identified_estimand)
```

    Estimand type: nonparametric-ate

    ### Estimand : 1
    Estimand name: backdoor1 (Default)
    Estimand expression:
         d
    ────────────(Expectation(log_demand|has_membership,is_US,friends_count,age,inc
    d[log_price]

    ome,days_visited,songs_purchased,account_age,avg_hours))

    Estimand assumption 1, Unconfoundedness: If U→{log_price} and U→log_demand then P(log_demand|log_price,has_membership,is_US,friends_count,age,income,days_visited,songs_purchased,account_age,avg_hours,U) = P(log_demand|log_price,has_membership,is_US,friends_count,age,income,days_visited,songs_purchased,account_age,avg_hours)

    ### Estimand : 2
    Estimand name: iv
    No such variable found!

    ### Estimand : 3
    Estimand name: frontdoor
    No such variable found!

# Get Causal Effects with EconML

Based on the identified causal effect above, we fit the model as follows using EconML:

\begin{align}
log(Y) & = \theta(X) \cdot log(T) + f(X,W) + \epsilon \\
log(T) & = g(X,W) + \eta
\end{align}

where $\epsilon, \eta$ are uncorrelated error terms.

The models we fit here aren't an exact match for the data generation function above, but if they are a good approximation, they will allow us to create a good discount policy.  Although the model is misspecified, we hope to see that our `DML` based estimators can still capture the right trend of $\theta(X)$ and that the recommended policy beats other baseline policies (such as always giving a discount) on revenue.  Because of the mismatch between the data generating process and the model we're fitting, there isn't a single true $\theta(X)$ (the true elasticity varies with not only X but also T and W), but given how we generate the data above, we can still calculate the range of true $\theta(X)$ to compare against.

```python
# Define underlying treatment effect function given DGP
def gamma_fn(X):
    return -3 - 14 * (X["income"] < 1)

def beta_fn(X):
    return 20 + 0.5 * (X["avg_hours"]) + 5 * (X["days_visited"] > 4)

def demand_fn(data, T):
    Y = gamma_fn(data) * T + beta_fn(data)
    return Y

def true_te(x, n, stats):
    if x < 1:
        subdata = train_data[train_data["income"] < 1].sample(n=n, replace=True)
    else:
        subdata = train_data[train_data["income"] >= 1].sample(n=n, replace=True)
    te_array = subdata["price"] * gamma_fn(subdata) / (subdata["demand"])
    if stats == "mean":
        return np.mean(te_array)
    elif stats == "median":
        return np.median(te_array)
    elif isinstance(stats, int):
        return np.percentile(te_array, stats)
```

```python
# Get the estimate and range of true treatment effect
truth_te_estimate = np.apply_along_axis(true_te, 1, X_test, 1000, "mean")  # estimate
truth_te_upper = np.apply_along_axis(true_te, 1, X_test, 1000, 95)  # upper level
truth_te_lower = np.apply_along_axis(true_te, 1, X_test, 1000, 5)  # lower level
```

    INFO:numexpr.utils:NumExpr defaulting to 8 threads.

## Parametric heterogeneity
First of all, we can try to learn a **linear projection of the treatment effect** assuming a polynomial form of $\theta(X)$. We use the `LinearDML` estimator. Since we don't have any priors on these models, we use a generic gradient boosting tree estimators to learn the expected price and demand from the data.

```python
lineardml_estimate = est_dw.estimate_
print(lineardml_estimate)
```

    *** Causal Estimate ***

    ## Identified estimand
    Estimand type: nonparametric-ate

    ## Realized estimand
    b: log_demand~log_price+has_membership+is_US+friends_count+age+income+days_visited+songs_purchased+account_age+avg_hours | income
    Target units: ate

    ## Estimate
    Mean value: -0.9916221544699865
    Effect estimates: [-1.08504186 -1.47252767 -0.8270136  ... -1.32545964 -1.90661168
     -0.40606115]

```python
# Get treatment effect and its confidence interval
te_pred = est_dw.effect(X_test).flatten()
te_pred_interval = est_dw.effect_interval(X_test)
```

```python
# Compare the estimate and the truth
plt.figure(figsize=(10, 6))
plt.plot(X_test.flatten(), te_pred, label="Sales Elasticity Prediction")
plt.plot(X_test.flatten(), truth_te_estimate, "--", label="True Elasticity")
plt.fill_between(
    X_test.flatten(),
    te_pred_interval[0].flatten(),
    te_pred_interval[1].flatten(),
    alpha=0.2,
    label="90% Confidence Interval",
)
plt.fill_between(
    X_test.flatten(),
    truth_te_lower,
    truth_te_upper,
    alpha=0.2,
    label="True Elasticity Range",
)
plt.xlabel("Income")
plt.ylabel("Songs Sales Elasticity")
plt.title("Songs Sales Elasticity vs Income")
plt.legend(loc="lower right")
```

From the plot above, it's clear to see that the true treatment effect is a **nonlinear** function of income, with elasticity around -1.75 when income is smaller than 1 and a small negative value when income is larger than 1. The model fits a quadratic treatment effect, which is not a great fit. But it still captures the overall trend: the elasticity is negative and people are less sensitive to the price change if they have higher income.

```python
# Get the final coefficient and intercept summary
est_dw.summary(feature_names=["income"])
```

Coefficient Results

      	     point_estimate	 stderr	  zstat	  pvalue	 ci_lower	 ci_upper

  income	        2.46	       0.075	  32.86	    0.0	    2.337	    2.583

  income^2	     -0.451	      0.026	 -17.496	   0.0	   -0.493	   -0.408

CATE Intercept Results

         	        point_estimate	 stderr	  zstat	  pvalue	 ci_lower	 ci_upper

  cate_intercept	     -3.032	      0.046	 -65.801	   0.0	   -3.108	   -2.957

A linear parametric conditional average treatment effect (CATE) model was fitted:
$Y = \Theta(X)\cdot T + g(X, W) + \epsilon$
where for every outcome $i$ and treatment $j$ the CATE $\Theta_{ij}(X)$ has the form:
$\Theta_{ij}(X) = \phi(X)' coef_{ij} + cate\_intercept_{ij}$
where $\phi(X)$ is the output of the `featurizer` or $X$ if `featurizer`=None. Coefficient Results table portrays the $coef_{ij}$ parameter vector for each outcome $i$ and treatment $j$. Intercept Results table portrays the $cate\_intercept_{ij}$ parameter.

`LinearDML` estimator can also return the summary of the coefficients and intercept for the final model, including point estimates, p-values and confidence intervals. From the table above, we notice that $income$ has positive effect and ${income}^2$ has negative effect, and both of them are statistically significant.

## Nonparametric Heterogeneity
Since we already know the true treatment effect function is nonlinear, let us fit another model using `CausalForestDML`, which assumes a fully **nonparametric estimation of the treatment effect**.

```python
# initiate an EconML cate estimator
est_nonparam = CausalForestDML(model_y=GradientBoostingRegressor(), model_t=GradientBoostingRegressor())
# fit through dowhy
est_nonparam_dw = est_nonparam.dowhy.fit(Y, T, X=X, W=W, outcome_names=["log_demand"], treatment_names=["log_price"],
                                         feature_names=["income"], confounder_names=confounder_names, inference="blb")
```

    WARNING:dowhy.causal_model:Causal Graph not provided. DoWhy will construct a graph based on data inputs.
    INFO:dowhy.causal_graph:If this is observed data (not from a randomized experiment), there might always be missing confounders. Adding a node named "Unobserved Confounders" to reflect this.
    INFO:dowhy.causal_model:Model to find the causal effect of treatment ['log_price'] on outcome ['log_demand']
    WARNING:dowhy.causal_identifier:If this is observed data (not from a randomized experiment), there might always be missing confounders. Causal effect cannot be identified perfectly.
    INFO:dowhy.causal_identifier:Continuing by ignoring these unobserved confounders because proceed_when_unidentifiable flag is True.
    INFO:dowhy.causal_identifier:Instrumental variables for treatment and outcome:[]
    INFO:dowhy.causal_identifier:Frontdoor variables for treatment and outcome:[]
    INFO:dowhy.causal_estimator:INFO: Using EconML Estimator
    INFO:dowhy.causal_estimator:b: log_demand~log_price+has_membership+is_US+friends_count+age+income+days_visited+songs_purchased+account_age+avg_hours | income

```python
# Get treatment effect and its confidence interval
te_pred = est_nonparam_dw.effect(X_test).flatten()
te_pred_interval = est_nonparam_dw.effect_interval(X_test)
```

```python
# Compare the estimate and the truth
plt.figure(figsize=(10, 6))
plt.plot(X_test.flatten(), te_pred, label="Sales Elasticity Prediction")
plt.plot(X_test.flatten(), truth_te_estimate, "--", label="True Elasticity")
plt.fill_between(
    X_test.flatten(),
    te_pred_interval[0].flatten(),
    te_pred_interval[1].flatten(),
    alpha=0.2,
    label="90% Confidence Interval",
)
plt.fill_between(
    X_test.flatten(),
    truth_te_lower,
    truth_te_upper,
    alpha=0.2,
    label="True Elasticity Range",
)
plt.xlabel("Income")
plt.ylabel("Songs Sales Elasticity")
plt.title("Songs Sales Elasticity vs Income")
plt.legend(loc="lower right")
```

We notice that this model fits much better than the `LinearDML`, the 90% confidence interval correctly covers the true treatment effect estimate and captures the variation when income is around 1. Overall, the model shows that people with low income are much more sensitive to the price changes than higher income people.

# Test Estimate Robustness with DoWhy

### Add Random Common Cause

How robust are our estimates to adding another confounder? We use DoWhy to test this!

```python
res_random = est_nonparam_dw.refute_estimate(method_name="random_common_cause")
print(res_random)
```

    INFO:dowhy.causal_estimator:INFO: Using EconML Estimator
    INFO:dowhy.causal_estimator:b: log_demand~log_price+has_membership+is_US+friends_count+age+income+days_visited+songs_purchased+account_age+avg_hours+w_random | income

    Refute: Add a Random Common Cause
    Estimated effect:-0.9580402878312985
    New effect:-0.9543974584750428

### Add Unobserved Common Cause

How robust are our estimates to unobserved confounders? Since we assume the model is under unconfoundedness, adding an unobserved confounder might bias the estimates. We use DoWhy to test this!

```python
res_unobserved = est_nonparam_dw.refute_estimate(
    method_name="add_unobserved_common_cause",
    confounders_effect_on_treatment="linear",
    confounders_effect_on_outcome="linear",
    effect_strength_on_treatment=0.1,
    effect_strength_on_outcome=0.1,
)
print(res_unobserved)
```

    INFO:dowhy.causal_estimator:INFO: Using EconML Estimator
    INFO:dowhy.causal_estimator:b: log_demand~log_price+has_membership+is_US+friends_count+age+income+days_visited+songs_purchased+account_age+avg_hours | income

    Refute: Add an Unobserved Common Cause
    Estimated effect:-0.9580402878312985
    New effect:0.19244868852546373

### Replace Treatment with a Random (Placebo) Variable

What happens our estimates if we replace the treatment variable with noise? Ideally, the average effect would be wildly different than our original estimate. We use DoWhy to investigate!

```python
res_placebo = est_nonparam_dw.refute_estimate(
    method_name="placebo_treatment_refuter", placebo_type="permute",
    num_simulations=3
)
print(res_placebo)
```

    INFO:dowhy.causal_refuters.placebo_treatment_refuter:Refutation over 3 simulated datasets of permute treatment
    INFO:dowhy.causal_estimator:INFO: Using EconML Estimator
    INFO:dowhy.causal_estimator:b: log_demand~placebo+has_membership+is_US+friends_count+age+income+days_visited+songs_purchased+account_age+avg_hours | income
    INFO:dowhy.causal_estimator:INFO: Using EconML Estimator
    INFO:dowhy.causal_estimator:b: log_demand~placebo+has_membership+is_US+friends_count+age+income+days_visited+songs_purchased+account_age+avg_hours | income
    INFO:dowhy.causal_estimator:INFO: Using EconML Estimator
    INFO:dowhy.causal_estimator:b: log_demand~placebo+has_membership+is_US+friends_count+age+income+days_visited+songs_purchased+account_age+avg_hours | income
    WARNING:dowhy.causal_refuters.placebo_treatment_refuter:We assume a Normal Distribution as the sample has less than 100 examples.
                     Note: The underlying distribution may not be Normal. We assume that it approaches normal with the increase in sample size.

    Refute: Use a Placebo Treatment
    Estimated effect:-0.9580402878312985
    New effect:-0.0035545157067004107
    p value:0.2675050399595329

### Remove a Random Subset of the Data

Do we recover similar estimates on subsets of the data? This speaks to the ability of our chosen estimator to generalize well. We use DoWhy to investigate this!

```python
res_subset = est_nonparam_dw.refute_estimate(
    method_name="data_subset_refuter", subset_fraction=0.8,
    num_simulations=3)
print(res_subset)
```

    INFO:dowhy.causal_refuters.data_subset_refuter:Refutation over 0.8 simulated datasets of size 8000.0 each
    INFO:dowhy.causal_estimator:INFO: Using EconML Estimator
    INFO:dowhy.causal_estimator:b: log_demand~log_price+has_membership+is_US+friends_count+age+income+days_visited+songs_purchased+account_age+avg_hours | income
    INFO:dowhy.causal_estimator:INFO: Using EconML Estimator
    INFO:dowhy.causal_estimator:b: log_demand~log_price+has_membership+is_US+friends_count+age+income+days_visited+songs_purchased+account_age+avg_hours | income
    INFO:dowhy.causal_estimator:INFO: Using EconML Estimator
    INFO:dowhy.causal_estimator:b: log_demand~log_price+has_membership+is_US+friends_count+age+income+days_visited+songs_purchased+account_age+avg_hours | income
    WARNING:dowhy.causal_refuters.data_subset_refuter:We assume a Normal Distribution as the sample has less than 100 examples.
                     Note: The underlying distribution may not be Normal. We assume that it approaches normal with the increase in sample size.

    Refute: Use a subset of data
    Estimated effect:-0.9580402878312985
    New effect:-0.9533429945511095
    p value:0.16643169840197264

# Understand Treatment Effects with EconML
EconML includes interpretability tools to better understand treatment effects. Treatment effects can be complex, but oftentimes we are interested in simple rules that can differentiate between users who respond positively, users who remain neutral and users who respond negatively to the proposed changes.

The EconML `SingleTreeCateInterpreter` provides interperetability by training a single decision tree on the treatment effects outputted by the any of the EconML estimators. In the figure below we can see in dark red users respond strongly to the discount and the in white users respond lightly to the discount.

```python
intrp = SingleTreeCateInterpreter(include_model_uncertainty=True, max_depth=2, min_samples_leaf=10)
intrp.interpret(est_nonparam_dw, X_test)
plt.figure(figsize=(25, 5))
intrp.plot(feature_names=["income"], fontsize=12)
```

# Make Policy Decision with EconML
We want to make policy decisions to maximum the **revenue** instead of the demand. In this scenario,

\begin{align}
Rev & = Y \cdot T \\
    & = \exp^{log(Y)} \cdot T\\
    & = \exp^{(\theta(X) \cdot log(T) + f(X,W) + \epsilon)} \cdot T \\
    & = \exp^{(f(X,W) + \epsilon)} \cdot T^{(\theta(X)+1)}
\end{align}

With the decrease of price, revenue will increase only if $\theta(X)+1<0$. Thus, we set `sample_treatment_cast=-1` here to learn **what kinds of customers we should give a small discount to maximum the revenue**.

The EconML library includes policy interpretability tools such as `SingleTreePolicyInterpreter` that take in a treatment cost and the treatment effects to learn simple rules about which customers to target profitably. In the figure below we can see the model recommends to give discount for people with income less than $0.985$ and give original price for the others.

```python
intrp = SingleTreePolicyInterpreter(risk_level=0.05, max_depth=2, min_samples_leaf=1, min_impurity_decrease=0.001)
intrp.interpret(est_nonparam_dw, X_test, sample_treatment_costs=-1, treatment_names=["Discount", "No-Discount"])
plt.figure(figsize=(25, 5))
intrp.plot(feature_names=["income"], fontsize=12)
```

Now, let us compare our policy with other baseline policies! Our model says which customers to give a small discount to, and for this experiment, we will set a discount level of 10% for those users. Because the model is misspecified we would not expect good results with large discounts. Here, because we know the ground truth, we can evaluate the value of this policy.

```python
# define function to compute revenue
def revenue_fn(data, discount_level1, discount_level2, baseline_T, policy):
    policy_price = baseline_T * (1 - discount_level1) * policy + baseline_T * (1 - discount_level2) * (1 - policy)
    demand = demand_fn(data, policy_price)
    rev = demand * policy_price
    return rev
```

```python
policy_dic = {}
# our policy above
policy = intrp.treat(X)
policy_dic["Our Policy"] = np.mean(revenue_fn(train_data, 0, 0.1, 1, policy))

## previous strategy
policy_dic["Previous Strategy"] = np.mean(train_data["price"] * train_data["demand"])

## give everyone discount
policy_dic["Give Everyone Discount"] = np.mean(revenue_fn(train_data, 0.1, 0, 1, np.ones(len(X))))

## don't give discount
policy_dic["Give No One Discount"] = np.mean(revenue_fn(train_data, 0, 0.1, 1, np.ones(len(X))))

## follow our policy, but give -10% discount for the group doesn't recommend to give discount
policy_dic["Our Policy + Give Negative Discount for No-Discount Group"] = np.mean(revenue_fn(train_data, -0.1, 0.1, 1, policy))

## give everyone -10% discount
policy_dic["Give Everyone Negative Discount"] = np.mean(revenue_fn(train_data, -0.1, 0, 1, np.ones(len(X))))
```

```python
# get policy summary table
res = pd.DataFrame.from_dict(policy_dic, orient="index", columns=["Revenue"])
res["Rank"] = res["Revenue"].rank(ascending=False)
res
```

      Revenue
      Rank

      Our Policy
      14.686241
      2.0

      Previous Strategy
      14.349342
      4.0

      Give Everyone Discount
      13.774469
      6.0

      Give No One Discount
      14.294606
      5.0

      Our Policy + Give Negative Discount for No-Discount Group
      15.564411
      1.0

      Give Everyone Negative Discount
      14.612670
      3.0

**We beat the baseline policies!** Our policy gets the highest revenue except for the one raising the price for the No-Discount group. That means our currently baseline price is low, but the way we segment the user does help increase the revenue!

# Conclusions

In this notebook, we have demonstrated the power of using EconML and DoWhy to:

* Estimate the treatment effect correctly even the model is misspecified
* Test causal assumptions and investigate the robustness of the resulting estimates
* Interpret the resulting individual-level treatment effects
* Make the policy decision beats the previous and baseline policies

To learn more about what EconML can do for you, visit our [website](https://aka.ms/econml), our [GitHub page](https://github.com/microsoft/EconML) or our [docummentation](https://econml.azurewebsites.net/).

To learn more about what DoWhy can do for you, visit the [GitHub page](https://github.com/microsoft/dowhy) or [documentation](https://microsoft.github.io/dowhy/index.html).

notebooks/CustomerScenarios/Case Study - Customer Segmentation at An Online Media Company - EconML + DoWhy.ipynb


# `GiRaFFE_NRPy`: Solving the Induction Equation

## Author: Patrick Nelson

Our goal in this module is to write the code necessary to solve the induction equation
$$
\partial_t A_i = \underbrace{\epsilon_{ijk} v^j B^k}_{\rm No\ Gauge\ terms} - \underbrace{\partial_i \left(\alpha \Phi - \beta^j A_j \right)}_{\rm Gauge\ terms}
$$

Since we are not using staggered grids, we can greatly simplify this algorithm with respect to the version used in the original `GiRaFFE`. We turn to Tóth's [paper](https://www.sciencedirect.com/science/article/pii/S0021999100965197?via%3Dihub), Eqs. 30 and 31, and a 3D version of the same algorithm in **TODO: RIT paper**.

Consider the electric field $E_i = \epsilon_{ijk} v^j B^k$ (this relation assumes the ideal MHD limit, which is also assumed in FFE).

Consider the point $i,j,k$. Let components of tensors be indicated with braces, i.e. the $i^{\rm th}$ component of the electric field at point $i,j,k$ will be written as $\left(E_{\{i\}}\right)_{i,j,k}$. Then
\begin{align}
\left(E_{\{1\}}\right)_{i,j,k} = \frac{1}{4}(v^{\{2\}})B^{\{3\}})_{i,j-\tfrac{1}{2},k} &+ \frac{1}{4}(v^{\{2\}})B^{\{3\}})_{i,j+\tfrac{1}{2},k}\\
- \frac{1}{4}(v^{\{3\}})B^{\{2\}})_{i,j,k-\tfrac{1}{2}} &- \frac{1}{4}(v^{\{3\}})B^{\{2\}})_{i,j,k+\tfrac{1}{2}}
\end{align}

The other components follow via a cyclic permutation of the indices. Note a potential complication here: When we are calculating $i^{\rm th}$ component of the electric field, we are concerned with the reconstructed quantities in the $j^{\rm th}$ and $k^{\rm th}$ directions. This means that it will be sensible to do something similar to what we do with the A2B module and think first about the directions in which a stencil goes, and *then* the terms that involve it.

In this case, we will compute the face-value products of $v^i$ and $B^i$ in, say, the 0th direction **TODO: rectify off-by-one above**. Then, we will compute the parts of components of the electric field that depend on those: the 1st and 2nd direction.

An outline of a general finite-volume method is as follows, with the current step in bold:
1. The Reconstruction Step - Piecewise Parabolic Method
    1. Within each cell, fit to a function that conserves the volume in that cell using information from the neighboring cells
        * For PPM, we will naturally use parabolas
    1. Use that fit to define the state at the left and right interface of each cell
    1. Apply a slope limiter to mitigate Gibbs phenomenon
1. Interpolate the value of the metric gridfunctions on the cell faces
1. **Solving the Riemann Problem - Harten, Lax, (This notebook, $E_i$ only)**
    1. **Use the left and right reconstructed states to calculate the unique state at boundary**

We will assume in this notebook that the reconstructed velocities and magnetic fields are available on cell faces as input. We will also assume that the metric gridfunctions have been interpolated on the metric faces.

Solving the Riemann problem, then, consists of two substeps: First, we compute the flux through each face of the cell. Then, we add the average of these fluxes to the right-hand side of the evolution equation for the vector potential.

# Table of Contents
$$\label{toc}$$

This notebook is organized as follows:

1. [Step 1](#prelim): Preliminaries
1. [Step 2](#b_i_flux): Computing the Magnetic Flux
    1. [Step 2.a](#hydro_speed): GRFFE characteristic wave speeds
    1. [Step 2.b](#fluxes): Compute the HLLE fluxes
1. [Step 3](#code_validation): Code Validation against `GiRaFFE_NRPy.Induction_Equation` NRPy+ Module
1. [Step 4](#latex_pdf_output): Output this notebook to $\LaTeX$-formatted PDF file

# Step 1: Preliminaries \[Back to [top](#toc)\]
$$\label{prelim}$$

We begin by importing the NRPy+ core functionality. We also import the Levi-Civita symbol, the GRHD module, and the GRFFE module.

```python
# Step 0: Add NRPy's directory to the path
# https://stackoverflow.com/questions/16780014/import-file-from-parent-directory
import os,sys
nrpy_dir_path = os.path.join("..")
if nrpy_dir_path not in sys.path:
    sys.path.append(nrpy_dir_path)

from outputC import *            # NRPy+: Core C code output module
import finite_difference as fin  # NRPy+: Finite difference C code generation module
import NRPy_param_funcs as par   # NRPy+: Parameter interface
import grid as gri               # NRPy+: Functions having to do with numerical grids
import loop as lp                # NRPy+: Generate C code loops
import indexedexp as ixp         # NRPy+: Symbolic indexed expression (e.g., tensors, vectors, etc.) support
import reference_metric as rfm   # NRPy+: Reference metric support
import cmdline_helper as cmd     # NRPy+: Multi-platform Python command-line interface
import shutil, os, sys           # Standard Python modules for multiplatform OS-level functions

thismodule = "GiRaFFE_NRPy-Afield_flux"

import GRHD.equations as GRHD
import GRFFE.equations as GRFFE

# Import the Levi-Civita symbol and build the corresponding tensor.
# We already have a handy function to define the Levi-Civita symbol in WeylScalars
import WeylScal4NRPy.WeylScalars_Cartesian as weyl

```

# Step 2: Computing the Magnetic Flux \[Back to [top](#toc)\]
$$\label{b_i_flux}$$

## Step 2.a: GRFFE characteristic wave speeds \[Back to [top](#toc)\]
$$\label{hydro_speed}$$

This function is identical to the one done by Stilde_flux. See [that tutorial](Tutorial-GiRaFFE_NRPy-Stilde-flux.ipynb#hydro_speed) for further information on the derivation.

```python
# We'll write this as a function so that we can calculate the expressions on-demand for any choice of i
def find_cp_cm(lapse,shifti,gupii):
    # Inputs:  u0,vi,lapse,shift,gammadet,gupii
    # Outputs: cplus,cminus

    # a = 1/(alpha^2)
    a = 1/(lapse*lapse)
    # b = 2 beta^i / alpha^2
    b = 2 * shifti /(lapse*lapse)
    # c = -g^{ii} + (beta^i)^2 / alpha^2
    c = - gupii + shifti*shifti/(lapse*lapse)

    # Now, we are free to solve the quadratic equation as usual. We take care to avoid passing a
    # negative value to the sqrt function.
    detm = b*b - 4*a*c
    detm = sp.sqrt(sp.Rational(1,2)*(detm + nrpyAbs(detm)))
    global cplus,cminus
    cplus  = sp.Rational(1,2)*(-b/a + detm/a)
    cminus = sp.Rational(1,2)*(-b/a - detm/a)

```

This function is identical to the one done by Stilde_flux. For more information, see [here](Tutorial-GiRaFFE_NRPy-Stilde-flux.ipynb#fluxes).

```python
# We'll write this as a function, and call it within HLLE_solver, below.
def find_cmax_cmin(flux_dirn,gamma_faceDD,beta_faceU,alpha_face):
    # Inputs:  flux direction flux_dirn, Inverse metric gamma_faceUU, shift beta_faceU,
    #          lapse alpha_face, metric determinant gammadet_face
    # Outputs: maximum and minimum characteristic speeds cmax and cmin
    # First, we need to find the characteristic speeds on each face
    gamma_faceUU,unusedgammaDET = ixp.generic_matrix_inverter3x3(gamma_faceDD)
    find_cp_cm(alpha_face,beta_faceU[flux_dirn],gamma_faceUU[flux_dirn][flux_dirn])
    cpr = cplus
    cmr = cminus
    find_cp_cm(alpha_face,beta_faceU[flux_dirn],gamma_faceUU[flux_dirn][flux_dirn])
    cpl = cplus
    cml = cminus

    # The following algorithms have been verified with random floats:

    global cmax,cmin
    # Now, we need to set cmax to the larger of cpr,cpl, and 0
    cmax = sp.Rational(1,2)*(cpr+cpl+nrpyAbs(cpr-cpl))
    cmax = sp.Rational(1,2)*(cmax+nrpyAbs(cmax))

    # And then, set cmin to the smaller of cmr,cml, and 0
    cmin =  sp.Rational(1,2)*(cmr+cml-nrpyAbs(cmr-cml))
    cmin = -sp.Rational(1,2)*(cmin-nrpyAbs(cmin))

```

## Step 2.b: Compute the HLLE fluxes \[Back to [top](#toc)\]
$$\label{fluxes}$$

See GRHydro paper for equations (TBA)**TODO**

Here, we we calculate the flux and state vectors for the electric field.
The flux vector in the $i^{\rm th}$ direction is given as
$$
F(U) = \epsilon_{ijk} v^j B^k,
$$
where $\epsilon_{ijk} = [ijk]\sqrt{\gamma}$, $[ijk]$ is the Levi-Civita symbol, $\gamma$ is the determinant of the three-metric, and $v^j = \alpha \bar{v^j} - \beta^j$ is the drift velocity; the state vector in the $i^{\rm th}$ direction is $U = B^i$.

```python
def calculate_flux_and_state_for_Induction(flux_dirn, gammaDD,betaU,alpha,ValenciavU,BU):
    GRHD.compute_sqrtgammaDET(gammaDD)
    # Here, we import the Levi-Civita tensor and compute the tensor with lower indices
    LeviCivitaDDD = weyl.define_LeviCivitaSymbol_rank3()
    for i in range(3):
        for j in range(3):
            for k in range(3):
                LCijk = LeviCivitaDDD[i][j][k]
                LeviCivitaDDD[i][j][k] = LCijk * GRHD.sqrtgammaDET

    global U,F
    # Flux F = \epsilon_{ijk} v^j B^k
    F = sp.sympify(0)
    for j in range(3):
        for k in range(3):
            F += LeviCivitaDDD[flux_dirn][j][k] * (alpha*ValenciavU[j]-betaU[j]) * BU[k]
    # U = B^i
    U = BU[flux_dirn]

```

Now, we write a standard HLLE solver based on eq. 3.15 in [the HLLE paper](https://epubs.siam.org/doi/pdf/10.1137/1025002),
$$
F^{\rm HLL} = \frac{c_{\rm min} F_{\rm R} + c_{\rm max} F_{\rm L} - c_{\rm min} c_{\rm max} (U_{\rm R}-U_{\rm L})}{c_{\rm min} + c_{\rm max}}
$$

```python
def HLLE_solver(cmax, cmin, Fr, Fl, Ur, Ul):
    # This solves the Riemann problem for the flux of E_i in one direction

    # F^HLL = (c_\min f_R + c_\max f_L - c_\min c_\max ( st_j_r - st_j_l )) / (c_\min + c_\max)
    return (cmin*Fr + cmax*Fl - cmin*cmax*(Ur-Ul) )/(cmax + cmin)
```

Here, we will use the function we just wrote to calculate the flux through a face. We will pass the reconstructed Valencia 3-velocity and magnetic field on either side of an interface to this function (designated as the "left" and "right" sides) along with the value of the 3-metric, shift vector, and lapse function on the interface. However, unlike when we used this method to calculate the flux term, the RHS of each component of $A_i$ does not depend on all three of the flux directions. Instead, the flux of one component of the $E_i$ field depends on flux through the faces in the other two directions. This will require us to write C code later to handle this.

Note that we allow the user to declare their own gridfunctions if they wish, and default to declaring basic symbols if they are not provided.

```python
def calculate_E_i_flux(inputs_provided=True,alpha_face=None,gamma_faceDD=None,beta_faceU=None,\
                       Valenciav_rU=None,B_rU=None,Valenciav_lU=None,B_lU=None):
    if not inputs_provided:
        # declare all variables
        alpha_face = sp.symbols(alpha_face)
        beta_faceU = ixp.declarerank1("beta_faceU")
        gamma_faceDD = ixp.declarerank2("gamma_faceDD","sym01")
        Valenciav_rU = ixp.declarerank1("Valenciav_rU")
        B_rU = ixp.declarerank1("B_rU")
        Valenciav_lU = ixp.declarerank1("Valenciav_lU")
        B_lU = ixp.declarerank1("B_lU")
    global E_fluxD
    E_fluxD = ixp.zerorank1()
    for flux_dirn in range(3):
        find_cmax_cmin(flux_dirn,gamma_faceDD,beta_faceU,alpha_face)
        calculate_flux_and_state_for_Induction(flux_dirn, gamma_faceDD,beta_faceU,alpha_face,\
                                               Valenciav_rU,B_rU)
        Fr = F
        Ur = U
        calculate_flux_and_state_for_Induction(flux_dirn, gamma_faceDD,beta_faceU,alpha_face,\
                                               Valenciav_lU,B_lU)
        Fl = F
        Ul = U
        E_fluxD[flux_dirn] += HLLE_solver(cmax, cmin, Fr, Fl, Ur, Ul)

```

Note that as currently written above, this code is written in a way that more resembles the algorithm described in Tóth's paper and the original `GiRaFFE`. Thus, we have found the flux through each face in our grid. Note that, for a given cell, the vector `E_fluxD` only describes three faces. By convention, these are the faces in the negative direction for each coordinate. The faces in the positive direction can thus be found by incrementing the appropriate index. With these values calculated, the next step would be to add the appropriate quantities to the right-hand side of $A_i$. However, with this need to use values stored at different indices, we will need to handwrite that portion of the code, which will be done elsewhere.

# Step 3:  Code Validation against `GiRaFFE_NRPy.Induction_Equation` NRPy+ Module \[Back to [top](#toc)\]
$$\label{code_validation}$$

Here, as a code validation check, we verify agreement in the SymPy expressions for the $\texttt{GiRaFFE}$ evolution equations and auxiliary quantities we intend to use between
1. this tutorial and
2. the NRPy+ [GiRaFFE_NRPy.Induction_Equation](../../edit/in_progress/GiRaFFE_NRPy/Induction_Equation.py) module.

Below are the gridfunction registrations we will need for testing. We will pass these to the above functions to self-validate the module that corresponds with this tutorial.

```python
all_passed=True
def comp_func(expr1,expr2,basename,prefixname2="C2P_P2C."):
    if str(expr1-expr2)!="0":
        print(basename+" - "+prefixname2+basename+" = "+ str(expr1-expr2))
        all_passed=False

def gfnm(basename,idx1,idx2=None,idx3=None):
    if idx2==None:
        return basename+"["+str(idx1)+"]"
    if idx3==None:
        return basename+"["+str(idx1)+"]["+str(idx2)+"]"
    return basename+"["+str(idx1)+"]["+str(idx2)+"]["+str(idx3)+"]"

# These are the standard gridfunctions we've used before.
#ValenciavU = ixp.register_gridfunctions_for_single_rank1("AUXEVOL","ValenciavU",DIM=3)
#gammaDD = ixp.register_gridfunctions_for_single_rank2("AUXEVOL","gammaDD","sym01")
#betaU = ixp.register_gridfunctions_for_single_rank1("AUXEVOL","betaU")
#alpha = gri.register_gridfunctions("AUXEVOL",["alpha"])
#AD = ixp.register_gridfunctions_for_single_rank1("EVOL","AD",DIM=3)
#BU = ixp.register_gridfunctions_for_single_rank1("AUXEVOL","BU",DIM=3)

# We will pass values of the gridfunction on the cell faces into the function. This requires us
# to declare them as C parameters in NRPy+. We will denote this with the _face infix/suffix.
alpha_face = gri.register_gridfunctions("AUXEVOL","alpha_face")
gamma_faceDD = ixp.register_gridfunctions_for_single_rank2("AUXEVOL","gamma_faceDD","sym01")
beta_faceU = ixp.register_gridfunctions_for_single_rank1("AUXEVOL","beta_faceU")

# We'll need some more gridfunctions, now, to represent the reconstructions of BU and ValenciavU
# on the right and left faces
Valenciav_rU = ixp.register_gridfunctions_for_single_rank1("AUXEVOL","Valenciav_rU",DIM=3)
B_rU = ixp.register_gridfunctions_for_single_rank1("AUXEVOL","B_rU",DIM=3)
Valenciav_lU = ixp.register_gridfunctions_for_single_rank1("AUXEVOL","Valenciav_lU",DIM=3)
B_lU = ixp.register_gridfunctions_for_single_rank1("AUXEVOL","B_lU",DIM=3)

import GiRaFFE_NRPy.Afield_flux as Af

expr_list = []
exprcheck_list = []
namecheck_list = []

calculate_E_i_flux(True,alpha_face,gamma_faceDD,beta_faceU,\
                   Valenciav_rU,B_rU,Valenciav_lU,B_lU)
Af.calculate_E_i_flux(True,alpha_face,gamma_faceDD,beta_faceU,\
                      Valenciav_rU,B_rU,Valenciav_lU,B_lU)

for flux_dirn in range(3):
    namecheck_list.extend([gfnm("E_fluxD",flux_dirn)])
    exprcheck_list.extend([Af.E_fluxD[flux_dirn]])
    expr_list.extend([E_fluxD[flux_dirn]])

for mom_comp in range(len(expr_list)):
    comp_func(expr_list[mom_comp],exprcheck_list[mom_comp],namecheck_list[mom_comp])

import sys
if all_passed:
    print("ALL TESTS PASSED!")
else:
    print("ERROR: AT LEAST ONE TEST DID NOT PASS")
    sys.exit(1)

```

    ALL TESTS PASSED!

# Step 4: Output this notebook to $\LaTeX$-formatted PDF file \[Back to [top](#toc)\]
$$\label{latex_pdf_output}$$

The following code cell converts this Jupyter notebook into a proper, clickable $\LaTeX$-formatted PDF file. After the cell is successfully run, the generated PDF may be found in the root NRPy+ tutorial directory, with filename
[Tutorial-GiRaFFE_NRPy-Induction_Equation.pdf](Tutorial-GiRaFFE_NRPy-Induction_Equation.pdf) (Note that clicking on this link may not work; you may need to open the PDF file through another means.)

```python
!jupyter nbconvert --to latex --template latex_nrpy_style.tplx --log-level='WARN' Tutorial-GiRaFFE_NRPy-Afield_flux.ipynb
!pdflatex -interaction=batchmode Tutorial-GiRaFFE_NRPy-Afield_flux.tex
!pdflatex -interaction=batchmode Tutorial-GiRaFFE_NRPy-Afield_flux.tex
!pdflatex -interaction=batchmode Tutorial-GiRaFFE_NRPy-Afield_flux.tex
!rm -f Tut*.out Tut*.aux Tut*.log
```

    This is pdfTeX, Version 3.14159265-2.6-1.40.18 (TeX Live 2017/Debian) (preloaded format=pdflatex)
     restricted \write18 enabled.
    entering extended mode
    This is pdfTeX, Version 3.14159265-2.6-1.40.18 (TeX Live 2017/Debian) (preloaded format=pdflatex)
     restricted \write18 enabled.
    entering extended mode
    This is pdfTeX, Version 3.14159265-2.6-1.40.18 (TeX Live 2017/Debian) (preloaded format=pdflatex)
     restricted \write18 enabled.
    entering extended mode


# On Kepler 452

Kepler 452 is a solar-like star in the Kepler field that was [recently announced to possess a planet](http://iopscience.iop.org/1538-3881/150/2/56/article) with an orbit of 385 Earth days. Based on a stellar evolution model analysis of the host star, the planet is found to have a radius of approximately $1.63 \pm 0.23 R_{\oplus}$. [Standard Dartmouth stellar models](http://adsabs.harvard.edu/abs/2008ApJS..178...89D) were used to draw this conclusion, with added support from a similar analysis performed with [YREC models](http://dx.doi.org/10.1086/424966). While I do not doubt the overall validity of the stellar models, it is still a worthwhile excerise to explore how various modeling assumptions may affect the results given that only three observable properties were used to constrain the model parameters: $\log(g)$, $T_{\rm eff}$, and [Fe/H].

Being by initializing matplotlib and numpy (eventually I'll add this to the default config)

```python
%matplotlib inline
import numpy as np
import matplotlib.pyplot as plt
```

## Solar Abundance Distribution

```python
fig, ax = plt.subplots(1, 1, figsize=(10, 10))

# configure axes
ax.set_xlabel('Effective Temperature (K)', fontsize=22.)
ax.set_xlim(6000., 5000.)
ax.set_ylabel('$\log_{10}(g)$', fontsize=22.)
ax.set_ylim(4.6, 4.1)
ax.tick_params(axis='both', which='major', length=20., labelsize=20.)

# approximate mass range from measured temperature
masses = np.arange(0.90, 1.3, 0.05)

# directories for metallicity of +0.2 dex
f15_directory = '../../evolve/dmestar/trk/gas07/p020/a0/amlt2202'
d08_directory = '../../evolve/dsep08/trk/fehp02afep0'

# plot mass tracks for GS98 and GAS07 composition
for mass in masses:
    f15_file = '{:s}/m{:04.0f}_GAS07_p020_p0_y27_mlt2.202.trk'.format(f15_directory, mass*1000.)
    d08_file = '{:s}/m{:03.0f}fehp02afep0.jc2mass'.format(d08_directory, mass*100.)

    try:
        f15_trk = np.genfromtxt(f15_file)
        d08_trk = np.genfromtxt(d08_file)
    except IOError:
        continue

    ax.plot(10**d08_trk[:, 1], d08_trk[:, 2], '--', lw=2, color='#444444')
    ax.plot(10**f15_trk[:, 1], f15_trk[:, 2], '-', lw=2, color='#333333')

# add Kepler 452 point
ax.errorbar([5757.], [4.32], xerr=85., yerr=0.09, fmt='-o', lw=3, markersize=14., color='#4682B4')

```

Note that there is not an $0.95 M_{\odot}$ mass track plotted. The right-most track for both model sets is a $0.90 M_{\odot}$ track. Assuming the star is burning hydrogen in the core and is _not_ on the pre-main-sequence, then we can estimate a mass of approximately $1.03\pm0.03 M_{\odot}$ from the Dartmouth 2008 models (dashed lines). If we instead look at the Dartmouth 2015 models, we find the mass is approximately $1.08\pm0.03 M_{\odot}$, consistent with the first esimate, within $2\sigma$. Once one builds in the metallicity uncertainty, the errors increase further, providing a greater consistency between the two measurements. Jenkins et al. quote a mass of $1.04\pm0.05 M_{\odot}$, in agreement with the aforementioned values.

One small factor that was not accounted for is that the observed metallicity provides the _present day_ metallicity, which is not necessarily equivalent to the quote metallicity for model mass tracks. Due to gravitational settling and multiple diffusive processes, one may need to use models with a higher proto-stellar (re: initial) surface metal abundance to acheive a present day value of [Fe/H] $= +0.2$ dex. At most, we might expect an 0.1 dex reduction in the surface abundance of heavy element over time. Although this is very rough, it should provide an upper limit to the uncertainty one expects heavy element diffusion to inflict on model properties.

```python
fig, ax = plt.subplots(1, 1, figsize=(10, 10))

# configure axes
ax.set_xlabel('Effective Temperature (K)', fontsize=22.)
ax.set_xlim(6000., 5000.)
ax.set_ylabel('$\log_{10}(g)$', fontsize=22.)
ax.set_ylim(4.6, 4.1)
ax.tick_params(axis='both', which='major', length=20., labelsize=20.)

# directories for metallicity of +0.2, +0.3 dex
d08_directory_03 = '../../evolve/dsep08/trk/fehp03afep0'

# plot mass tracks for GS98 and GAS07 composition
for mass in masses:
    d08_file = '{:s}/m{:03.0f}fehp02afep0.jc2mass'.format(d08_directory, mass*100.)
    d08_03_file = '{:s}/m{:03.0f}fehp03afep0.jc2mass'.format(d08_directory_03, mass*100.)

    try:
        d08_trk = np.genfromtxt(d08_file)
        d08_03_trk = np.genfromtxt(d08_03_file)
    except IOError:
        continue

    ax.plot(10**d08_trk[:, 1], d08_trk[:, 2], '--', lw=2, color='#444444')
    ax.plot(10**d08_03_trk[:, 1], d08_03_trk[:, 2], '-', lw=2, color='#333333')

# add Kepler 452 point
ax.errorbar([5757.], [4.32], xerr=85., yerr=0.09, fmt='-o', lw=3, markersize=14., color='#4682B4')
```

Dartmouth 2008 tracks with [Fe/H] $= +0.30$ dex are shown as solid lines, with dotted lines showing models computed with the present day metallicity of [Fe/H] = $+0.20$ dex. As was the case with the Dartmouth 2015 models, adopting a higher metallicity pushes the inferred stellar mass up to approximately $1.10 \pm 0.04 M_{\odot}$.

```python
fig, ax = plt.subplots(1, 1, figsize=(10, 8))

ax.set_title('Evolution of Surface Metallicity', fontsize=26., family='serif')
ax.set_xlabel('Age (Gyr)', fontsize=22., family='serif')
ax.set_xlim(1.0, 10.0)
ax.set_ylabel('[M/H] (dex)', fontsize=22., family='serif')
ax.tick_params(axis='both', which='major', length=16., labelsize=20.)

f15_trk = np.genfromtxt('{:s}/m1100_GAS07_p020_p0_y27_mlt2.202.trk'.format(f15_directory))
d08_trk = np.genfromtxt('../../evolve/models/tmp/m1100_GS98_p020_p0_y29_mlt1.884.trk')

# solar Z/X = 0.0165 for GAS07 solar abundance distribution, 0.0231 for GS98
ax.plot(f15_trk[:,0]/1.0e9, np.log10(f15_trk[:,7]/0.0165), '-', lw=3, color="#333333")
ax.plot(d08_trk[:,0]/1.0e9, np.log10(d08_trk[:,7]/0.0231), '--', lw=3, color="#333333")
```

Clearly a metallicity 0.1 dex above the quoted value is not a reasonable estimate, as it never dips as low as 0.2 dex. An initial metallicity in excess of 0.2 dex would be needed to derive precisely 0.2 dex as the present day metallicity for Kepler 452, but this is well within the uncertainty limits of ±0.08 dex derived from spectroscopy. While there may be a slight preference among all models for a higher mass than $1.04 M_{\odot}$, it is a valid estimate.

How does a 5% change in mass affect the radius and luminosity evolution of the star? If we take the gravity to be constant regardless of the derived mass, we can compute the fractional change in radius introduced by a change in mass,

\begin{equation}
    \frac{R_{\star}}{R_{\star,0}} = \left(\frac{M_{\star}}{M_{\star,0}}\right)^{1/2}.
\end{equation}

Therefore, a 5% change in stellar mass introduces a 3% increase on the stellar radius. However, since the planet radius is proportional to the projected surface area of the host star, the planet radius increases by approximately 5%, as well. The planet would therefore have a radius of $R_p = 1.71 R_{\oplus}$, which is entirely consistent with the Jenkins et al. estimate. This would further confirm the analysis of Jenkins et al. that lead to the conclusion that _it is unlikely that Kepler 452b has an Earth-like composition_.

The luminosity evolution would also be impacted by the increased radius, for at constant temperature, the luminosity of the star will increase by a factor proportional to $R_{\star}^2$. A 5% increase of indicdent flux is extremely minor in the grand scheme of things.

Indeed, even if the star were young, the mass estimate would increase by approximately 10% - 15%. This would increase the estimated planet radius by an equivalent amount, leading to a final planet mass $R_p = 2.0 R_{\oplus}$, which would still place the object in the super-Earth category. Although it would mean that the planet is even less likely to be rocky.

```python

```