Рівняння теплопровідності в tensorflow

Привіт, Хабр! Деякий час тому захопився глибоким навчанням і став потихеньку вивчати tensorflow. Поки копався в tensorflow згадав про свою курсову з паралельного програмування, яку робив в тому році на 4 курсі університету. Завдання там формулювалося так:

Лінійна початково-крайова задача для двовимірного рівняння теплопровідності:

\frac{\partial u}{\partial t} = \sum \limits_{\alpha=1}^{2} \frac{\partial}{\partial x_\alpha} \left (k_\alpha \frac{\partial u}{\partial x_\alpha} \right ) -u, \quad x_\alpha \in [0,1] \quad (\alpha=1,2), \ t>0;
k_\alpha =
\begin{cases}
50, (x_1, x_2) \in \Delta ABC\\
1, (x_1, x_2) \notin \Delta ABC
\end{cases}
(\alpha = 1,2), \ A(0.2,0.5), \ B(0.7,0.2), \ C(0.5,0.8);
u(x_1, x_2, 0) = 0,\ u(0,x_2,t) = 1 - e^{-\omega t},\ u(1, x_2, t) = 0,
u(x_1,0,t) = 1 - e^{-\omega t},\ u(0, x_2, t) = 0,\ \omega = 20.
Хоча правильніше було б назвати це рівнянням дифузії.

Завдання тоді вимагалося вирішити методом кінцевих різниць за неявною схемою, використовуючи MPI для розпаралелювання і метод спряжених градієнтів.

Я не фахівець у чисельних методах, поки не фахівець в tensorflow, але досвід у мене вже з'явився. І я загорівся бажанням спробувати обчислювати урматы на фреймворку для глибокого навчання. Метод спряжених градієнтів реалізовувати другий раз вже не цікаво, зате цікаво подивитися як з обчисленням впорається tensorflow і які труднощі при цьому виникнуть. Цей пост про те, що з цього вийшло.

Чисельний алгоритм

Визначимо сітку:
\Omega = \omega_{x_1} \times \omega_{x_2} \times \omega_t,
\[ \omega_{x_\alpha} = \left \{ x_{\alpha, i_\alpha} = i_\alpha h, i_\alpha = 0,...,N, h = \frac{1}{N}, \right \}\ \alpha = 1,2, \]
\[ \omega_t = \left \{t_j = j \tau, j=0,...,N_t, \tau = \frac{t_{max}}{N_t}\right \}.
Різницева схема:

Щоб простіше було розписувати, введемо оператори:

\Delta_{1}f_{i,j} = \frac{f_{i+1/2,j} - f_{i-1/2,j}}{h},
\Delta_{2}f_{i,j} = \frac{f_{i,j+1/2} - f_{i,j-1/2}}{h}.
Явна різницева схема:

\frac{u_{i,j}^t - u_{i,j}^{t-1}}{\tau} = \Delta_{1}(k_{i,j}\Delta_{1}u_{i,j}^{t-1}) + \Delta_{2}(k_{i,j}\Delta_{2}u_{i,j}^{t-1}) - u_{i,j}^t.
У разі явної різницевої схеми для обчислення використовуються значення функції в попередній момент часу і не потрібно розв'язувати рівняння на значення u^t_{i,j}. Однак така схема менш точна і вимагає значно менший крок по часу.

Неявна різницева схема:

\frac{u_{i,j}^t - u_{i,j}^{t-1}}{\tau} = \Delta_{1}(k_{i,j}\Delta_{1}u_{i,j}^t) + \Delta_{2}(k_{i,j}\Delta_{2}u_{i,j}^t) - u_{i,j}^t,
\frac{u_{i,j}^t - u_{i,j}^{t-1}}{\tau} = \Delta_{1}(k_{i,j}\frac{u_{i+1/2,j}^t - u_{i-1/2,j}^t}{h}) + 
\Delta_{2}(k_{i,j}\frac{u_{i,j+1/2}^t - u_{i,j-1/2}^t}{h}) - u_{i,j}^t
\frac{u_{i,j}^t - u_{i,j}^{t-1}}{\tau} = \frac{k_{i+1/2,j}\frac{u_{i+1,j}^t - u_{i,j}^t}{h} - k_{i-1/2,j}\frac{u_{i,j}^t - u_{i-1/2,j}^t}{h}}{h} +
\frac{k_{i,j+1/2}\frac{u_{i,j+1}^t - u_{i,j}^t}{h} - k_{i,j-1/2}\frac{u_{i,j}^t - u_{i,j-1/2}^t}{h}}{h} - u_{i,j}^t
\frac{u_{i,j}^t - u_{i,j}^{t-1}}{\tau} = \frac{k_{i+1/2,j}u_{i+1,j}^t - u_{i,j}^t - k_{i-1/2,j}u_{i,j}^t - u_{i-1/2,j}^t + k_{i,j+1/2}u_{i,j+1}^t - u_{i,j}^t - k_{i,j-1/2}u_{i,j}^t - u_{i,j-1/2}^t}{h^2} - u_{i,j}^t.
Перенесемо в ліву сторону все пов'язане з u^t, а в праву u^{t-1}і домножим на \tau:

(1 + \frac{\tau}{h^2}(k_{i+1/2,j} + k_{i-1/2,j} + k_{i,j+1/2} + k_{i,j-1/2}) + \tau)u_{i,j}^t - \\ - \frac{\tau}{h^2}(k_{i+1/2,j}u_{i+1,j}^t + k_{i-1/2,j}u^t_{i-1,j} + k_{i,j+1/2}u^t_{i,j+1} + k_{i,j-1/2}u^t_{i,j-1}) = u^{t-1}_{i,j}.
По суті, ми отримали операторний рівняння над сіткою:

Au^t = u^{t-1},
що, якщо записати значення u^tу вузлах сітки як звичайний вектор, є звичайною системою лінійних рівнянь (Ax = b). Значення в попередній момент часу константи, так як вже розраховані.
Для зручності подамо оператор Aяк різниця двох операторів:

A = D_A - (A^+ + A^{-}),
де:

D_A u^t = (1 + \frac{\tau}{h^2}(k_{i+1/2,j} + k_{i-1/2,j} + k_{i,j+1/2} + k_{i,j-1/2}) + \tau) u^t_{i,j},
(A^+ + A^{-})u^t = \frac{\tau}{h^2}(k_{i+1/2,j}u^t_{i+1,j} + k_{i-1/2,j}u^t_{i-1,j} +
k_{i,j+1/2}u^t_{i,j+1} + k_{i,j-1/2}u^t_{i,j-1}).
Замінивши u^tна нашу оцінку \hat{u}^t, запишемо функціонал помилки:

r = A\hat{u}^t - u^{t-1} = (D_A - A^+ - A^{-})\hat{u}^t - u^{t-1},
L = \sum r_{i,j}^2.
де r_{i,j}— помилка у вузлах сітки.

Будемо ітераційно мінімізувати функціонал помилки, використовуючи градієнт.

У підсумку задача звелася до перемножению тензорів і градиентному спуску, а це саме те, для чого tensorflow і був задуманий.

Реалізація на tensorflow

Коротко про tensorflow

У tensorflow спочатку будується граф обчислень. Ресурси під граф виділяються всередині tf.Session. Вузли графа — це операції над даними. Осередками для вхідних даних граф служать tf.placeholder. Щоб виконати граф, треба в об'єкта сесії запустити метод run, передавши в нього потрібну операцію і вхідні дані для плейсхолдеров. Метод run поверне результат виконання операції, а також може змінити значення всередині tf.Variable в рамках сесії.

tensorflow сам вміє будувати графи операцій, що реалізують backpropogation градієнта, за умови, що в оригінальному графі присутні лише операції, для яких реалізований градієнт (поки не у всіх).

Код:

Спочатку код ініціалізації. Тут виробляємо всі попередні операції і вважаємо все, що можна порахувати заздалегідь.

Ініціалізація
# Імпорти
import numpy as np
import pandas as pd
import tensorflow as tf
import matplotlib.pyplot as plt
import matplotlib
from matplotlib.animation import FuncAnimation
from matplotlib import cm
import seaborn


# Клас інкапсульовану логіку ініціалізації, виконання та
# навчання графа рівняння теплопровідності
class HeatEquation():
def __init__(self, nxy, tmax, nt, k, f, u0, u0yt, u1yt, ux0t, ux1t):
self._nxy = nxy # точок в напрямку x, y
self._tmax = tmax # масимальне час
self._nt = nt # кількість моментів часу
self._k = k # функція k
self._f = f # функція f
self._u0 = u0 # початкова умова
# крайові умови
self._u0yt = u0yt 
self._u1yt = u1yt
self._ux0t = ux0t
self._ux1t = ux1t
# кроки по координатах і часу
self._h = h = np.array(1./nxy)
self._ht = ht = np.array(tmax/nt)
print("ht/h/h:", ht/h/h)

self._xs = xs = np.linspace(0., 1., nxy + 1)
self._ys = ys = np.linspace(0., 1., nxy + 1)
self._ts = ts = np.linspace(0., tmax, nt + 1) 

from itertools import product
# вузли сітки як вектори у просторі
self._vs = vs = np.array(list(product(xs, ys)), dtype=np.float64)
# внутрішні вузли
self._vcs = vsc = np.array(list(product(xs[1:-1], ys[1:-1])), dtype=np.float64)

# векторые в яких розраховуються значення k
vkxs = np.array(list(product((xs+h/2)[:-1], ys)), dtype=np.float64) # k_i+0.5,j
vkys = np.array(list(product(xs, (ys+h/2)[:-1])), dtype=np.float64) # k_i ,j+0.5

# сітки зі значеннями k
self._kxs = kxs = k(vkxs).reshape((nxy,nxy+1))
self._kys = kys = k(vkys).reshape((nxy+1,nxy))

# діагональний оператор D_A 
D_A = np.zeros((nxy+1, nxy+1))
D_A[0:nxy+1,0:nxy+0] += kys
D_A[0:nxy+1,1:nxy+1] += kys
D_A[0:nxy+0,0:nxy+1] += kxs
D_A[1:nxy+1,0:nxy+1] += kxs
self._D_A = D_A = 1 + ht/h/h*D_A[1:nxy,1:nxy] + ht

# функція, яку будемо шукати
self._U_shape = (nxy+1, nxy+1, nt+1)
# виділяємо відразу для всіх точок і моментів часу,
# дуже багато зайвої пам'яті, але мені не шкода
self._U = np.zeros(self._U_shape) 
# її значення в нульовий момент часу
self._U[:,:,0] = u0(vs).reshape(self._U_shape[:-1])


\tauhслід брати такими, щоб \frac{\tau}{h^2}було невеликим, бажано, хоча б < 1, особливо при використанні «негладких функцій.

Метод, який будує граф рівняння:

Граф обчислень
# метод, який будує граф
def build_graph(self, learning_rate):
def reset_graph():
if 'sess' in globals() and sess:
sess.close()
tf.reset_default_graph()

reset_graph()

nxy = self._nxy

# вхідні параметри
kxs_ = tf.placeholder_with_default(self._kxs, (nxy,nxy+1))
kys_ = tf.placeholder_with_default(self._kys, (nxy+1,nxy))
D_A_ = tf.placeholder_with_default(self._D_A, self._D_A.shape)
U_prev_ = tf.placeholder(tf.float64, (nxy+1, nxy+1), name="U_t-1")
f_ = tf.placeholder(tf.float64, (nxy-1, nxy-1), name="f")

# значення функції в даний момент часу, його і будемо шукати
U_ = tf.Variable(U_prev_, trainable=True, name="U_t", dtype=tf.float64)

# зріз тензора
def s(tensor, frm):
return tf.slice(tensor, frm, (nxy-1, nxy-1))

# обчислення дії оператора A+_A - на u
Ap_Am_U_ = s(U_, (0, 1))*s(self._kxs, (0, 1))
Ap_Am_U_ += s(U_, (2, 1))*s(self._kxs, (1, 1))
Ap_Am_U_ += s(U_, (1, 0))*s(self._kys, (1, 0))
Ap_Am_U_ += s(U_, (1, 2))*s(self._kys, (1, 1))
Ap_Am_U_ *= self._ht/self._h/self._h

# залишки
res = D_A_*s(U_,(1, 1)) - Ap_Am_U_ - s(U_prev_, (1, 1)) - self._ht*f_

# функціонал помилки, який буде оптимізуватися
loss = tf.reduce_sum(tf.square(res), name="loss_res")

# крайові умови та їх впливу на функцію втрат
u0yt_ = None
u1yt_ = None
ux0t_ = None
ux1t_ = None
if self._u0yt: 
u0yt_ = tf.placeholder(tf.float64, (nxy+1,), name="u0yt")
loss += tf.reduce_sum(tf.square(tf.slice(U_, (0, 0), (1, nxy+1))
- tf.reshape(u0yt_, (1, nxy+1))), name="loss_u0yt")
if self._u1yt:
u1yt_ = tf.placeholder(tf.float64, (nxy+1,), name="u1yt")
loss += tf.reduce_sum(tf.square(tf.slice(U_, (nxy, 0), (1, nxy+1))
- tf.reshape(u1yt_, (1, nxy+1))), name="loss_u1yt")
if self._ux0t:
ux0t_ = tf.placeholder(tf.float64, (nxy+1,), name="ux0t")
loss += tf.reduce_sum(tf.square(tf.slice(U_, (0, 0), (nxy+1, 1))
- tf.reshape(ux0t_, (nxy+1, 1))), name="loss_ux0t")
if self._ux1t:
ux1t_ = tf.placeholder(tf.float64, (nxy+1,), name="ux1t")
loss += tf.reduce_sum(tf.square(tf.slice(U_, (0, nxy), (nxy+1, 1))
- tf.reshape(ux1t_, (nxy+1, 1))), name="loss_ux1t")
# на диво, у операції присвоєння значення окремих елементів тензоре, 
# на момент написання, ні реалізованого градієнта

loss /= (nxy+1)*(nxy+1)

# крок оптимізації функціонала 
train_step = tf.train.AdamOptimizer(learning_rate, 0.7, 0.97).minimize(loss)

# повернення в словнику операцій графа, які будемо запускати
self.g = dict(
U_prev = U_prev_,
f = f_,
u0yt = u0yt_,
u1yt = u1yt_,
ux0t = ux0t_,
ux1t = ux1t_,
U = U_,
res = res,
loss = loss,
train_step = train_step
)
return self.g


По-хорошому треба було вважати значення функції на краях заданими і оптимізувати значення функції тільки у внутрішній області, але з цим виникли проблеми. Способи зробити оптимизируемым тільки частина тензора не знайшлося, і в операції присвоєння значення зрізу тензора не написаний градієнт (на момент написання посту). Можна було б спробувати хитро повозитися на краях або написати свій оптимізатор. Але і просто додавання різниці на краях значень функції та крайових умов у функціонал помилки добре працює.

Варто зазначити, що метод з адаптивним моментом показав себе найкращим чином, нехай функціонал помилки і квадратичний.

Обчислення функції: у кожен момент часу робимо кілька оптимізаційних ітерацій, поки не перевищимо maxiter або помилка не стане менше eps, зберігаємо і переходимо до наступного моменту.

Обчислення
def train_graph(self, eps, maxiter, miniter):
g = self.g
losses = []
# запусків контекст сесії
with tf.Session() as sess:
# ініціалізуємо місце під дані в графі
sess.run(tf.global_variables_initializer(), feed_dict=self._get_graph_feed(0))
for t_i, t in enumerate(self._ts[1:]):
t_i += 1
losses_t = []
losses.append(losses_t)
d = self._get_graph_feed(t_i)
p_loss = float("inf")
for i in range(maxiter):
# запускаємо граф ітерації оптимізації
# і отримуємо значення u, функціоналу втрат 
_, self._U[:,:,t_i], loss = sess.run([g["train_step"], g["U"], g["loss"]], feed_dict=d)
losses_t.append(loss)
if i > miniter and abs(p_loss - loss) < eps:
p_loss = loss
break
p_loss = loss
print('#', end="")
return self._U, losses


Запуск:

Код запуску
tmax = 0.5
nxy = 100
nt = 10000

A = np.array([0.2, 0.5])
B = np.array([0.7, 0.2])
C = np.array([0.5, 0.8])

k1 = 1.0
k2 = 50.0
omega = 20

# перевірка належності точки трикутника
def triang(v, k1, k2, A, B, C):
v_ = v.copy()
k = k1*np.ones([v.shape[0]])
v_ = v - A
B_ = B - A
C_ = C - A
m = (v_[:, 0]*B_[1] - v_[:, 1]*B_[0]) / (C_[0]*B_[1] - C_[1]*B_[0])
l = (v_[:, 0] - m*C_[0]) / B_[0]
inside = (m > 0.) * (l > 0.) * (m + l < 1.0)
k[inside] = k2
return k

# 0.0
def f(v, t):
return 0*triang(v, h0, h1, A, B, C)

# 0.0
def u0(v):
return 0*triang(v, t1, t2, A, B, C)

# крайові умови
def u0ytb(t, ys):
return 1 - np.exp(-omega*np.ones(ys.shape[0])*t)

def ux0tb(t, xs):
return 1 - np.exp(-omega*np.ones(xs.shape[0])*t)

def u1ytb(t, ys):
return 0.*np.ones(ys.shape[0])

def ux1tb(t, xs):
return 0.*np.ones(xs.shape[0])

# запуск та отримання результату
eq = HeatEquation(nxy, tmax, nt, lambda x: triang(x, k1, k2, A, B, C), f, u0, u0ytb, u1ytb, ux0tb, ux1tb)
_ = eq.build_graph(0.001)
U, losses = eq.train_graph(1e-6, 100, 1)


Результати
Оригінальне умова:

Прихований текст


Умова як і оригінальна, але без -uу рівнянні:

Прихований текст\frac{\partial u}{\partial t} = \sum \limits_{\alpha=1}^{2} \frac{\partial}{\partial x_\alpha} \left (k_\alpha \frac{\partial u}{\partial x_\alpha} \right ), \quad x_\alpha \in [0,1] \quad (\alpha=1,2), \ t&amp;gt;0;
k_\alpha =
\begin{cases}
50, (x_1, x_2) \in \Delta ABC\\
1, (x_1, x_2) \notin \Delta ABC
\end{cases}
u(x_1, x_2, 0) = 0,\ u(0,x_2,t) = 1 - e^{-\omega t},\ u(1, x_2, t) = 0,
u(x_1,0,t) = 1 - e^{-\omega t},\ u(0, x_2, t) = 0,\ \omega = 20.
Що легко правиться в коді:

# в методі __init__
self._D_A = D_A = 1 + ht/h/h*D_A[1:nxy,1:nxy]

Різниці майже немає, тому що похідні мають великі порядки, ніж сама функція.




Далі скрізь:

\frac{\partial u}{\partial t} = \sum \limits_{\alpha=1}^{2} \frac{\partial}{\partial x_\alpha} \left (k_\alpha \frac{\partial u}{\partial x_\alpha} \right ) +f, \quad x_\alpha \in [0,1] \quad (\alpha=1,2), \ t&amp;gt;0;

Умова з одним нагревающимся краєм:

Прихований текстk_\alpha =
\begin{cases}
10, (x_1, x_2) \in \Delta ABC\\
1, (x_1, x_2) \notin \Delta ABC
\end{cases}
f(x_1,x_2,t) = 0,
u(x_1, x_2, 0) = 0,\ u(0,x_2,t) = 1 - e^{-\omega t},\ u(1, x_2, t) = 0,
u(x_1,0,t) = 0,\ u(0, x_2, t) = 0,\ \omega = 20.




Умова з остиганням спочатку нагрітої області:

Прихований текстk_\alpha = 1,
f(x_1,x_2,t) = 0,
u(x_1, x_2, 0) =
\begin{cases}
0.1, (x_1, x_2) \in \Delta ABC\\
0, (x_1, x_2) \notin \Delta ABC
\end{cases}
u(0,x_2,t) = 0,\ u(1, x_2, t) = 0,
u(x_1,0,t) = 0,\ u(0, x_2, t) = 0.




Умова з включенням нагріву в області:

Прихований текстk_\alpha =
\begin{cases}
2, (x_1, x_2) \in \Delta ABC\\
10, (x_1, x_2) \notin \Delta ABC
\end{cases}
f(x_1,x_2,t) =
\begin{cases}
10, (x_1, x_2) \in \Delta ABC\\
0, (x_1, x_2) \notin \Delta ABC
\end{cases}
u(x_1, x_2, 0) = 0,\ u(0,x_2,t) = 0,\ u(1, x_2, t) = 0,
u(x_1,0,t) = 0,\ u(0, x_2, t) = 0.




Малювання гифок
Функція малювання 3D-гифки:

3d gifВ основний клас додаємо метод, що повертає U у вигляді pandas.DataFrame

def get_U_as_df(self, step=1):
nxyp = self._nxy + 1
nxyp2 = nxyp**2
Uf = self._U.reshape((nxy+1)**2,-1)[:, ::step]
data = np.hstack((self._vs, Uf))
df = pd.DataFrame(data, columns=["x","y"] + list(range(len(self._ts))[0::step]))
return df

def make_gif(Udf, fname):
from mpl_toolkits.mplot3d import Axes3D
from matplotlib.ticker import LinearLocator, FormatStrFormatter
from scipy.interpolate import griddata

fig = plt.figure(figsize=(10,7))

ts = list(Udf.columns[2:])
data = Udf

# перетворимо сітку в дані, які вміє малювати matplotlib
x1 = np.linspace(data['x'].min(), data['x'].max(), len(data['x'].unique()))
y1 = np.linspace(data['y'].min(), data['y'].max(), len(data['y'].unique()))
x2, y2 = np.meshgrid(x1, y1)
z2s = list(map(lambda x: griddata((data['x'], data['y']), data[x], (x2, y2), 
method='cubic'), ts))

zmax = np.max(np.max(data.iloc[:, 2:])) + 0.01
zmin = np.min(np.min(data.iloc[:, 2:])) - 0.01

plt.grid(True)
ax = fig.gca(projection='3d')
ax.view_init(35, 15)
ax.set_zlim(zmin, zmax)
ax.zaxis.set_major_locator(LinearLocator(10))
ax.zaxis.set_major_formatter(FormatStrFormatter('%.02f'))

norm = matplotlib.colors.Normalize(vmin=zmin, vmax=zmax, clip=False)
surf = ax.plot_surface(x2, y2, z2s[0], rstride=1, cstride=1, norm=norm, cmap=cm.coolwarm, linewidth=0., antialiased=True)
fig.colorbar(surf, shrink=0.5, aspect=5)

# функція перемальовування зображення в новому кадрі
def update(t_i):
label = 'timestep {0}'.format(t_i)
ax.clear()
print(label)
surf = ax.plot_surface(x2, y2, z2s[t_i], rstride=1, cstride=1, norm=norm, cmap=cm.coolwarm, linewidth=0., antialiased=True)
ax.view_init(35, 15+0.5*t_i)
ax.set_zlim(zmin, zmax)
return surf,
# створення і збереження анімації
anim = FuncAnimation(fig, update, frames=range(len(z2s)), interval=50)
anim.save(fname, dpi=80, writer='imagemagick')


Функція малювання 2D-гифки:

2d gif
def make_2d_gif(U, fname, step=1):
fig = plt.figure(figsize=(10,7))

zmax = np.max(np.max(U)) + 0.01
zmin = np.min(np.min(U)) - 0.01
norm = matplotlib.colors.Normalize(vmin=zmin, vmax=zmax, clip=False)
im=plt.imshow(U[:,:,0], interpolation='bilinear', cmap=cm.coolwarm norm=norm)
plt.grid(False)
nst = U. shape[2] // step

# функція перемальовування зображення в новому кадрі
def update(i):
im.set_array(U[:,:,i*step])
return im
# створення і збереження анімації
anim = FuncAnimation(fig, update, frames=range(nst), interval=50)
anim.save(fname, dpi=80, writer='imagemagick')


Підсумок
Варто відзначити, що оригінальне умова без використання GPU вважалося 4м 26с, а з використанням GPU 2м 11с. При великих значеннях точок розрив зростає. Однак не всі операції в отриманому графі GPU сумісні.

Характеристики машини:

  • Intel Core i7 6700HQ 2600 МГц
  • NVIDIA GeForce GTX 960M.
Подивитися, які операції на що виконуються, можна за допомогою наступного коду:

Прихований текст
# В основному класі
def check_metadata_partitions_graph(self):
g = self.g
d = self._get_graph_feed(1)
with tf.Session() as sess:
sess.run(tf.global_variables_initializer(), feed_dict=d)
options = tf.RunOptions(output_partition_graphs=True)
metadata = tf.RunMetadata()
c_val = sess.run(g["train_step"], feed_dict=d, options=options, 
run_metadata=metadata)
print(metadata.partition_graphs)


Це був цікавий досвід. Tensorflow непогано показав себе для цієї задачі. Може бути навіть такий підхід отримає якесь застосування — всяко приємніше писати код на пітоні, ніж на C/C++, а з розвитком tensorflow стане ще простіше.

Спасибі за увагу!

Використана література
— Бахвалов Н. С., Жидков Н. П., Р. М. Кобельков Чисельні методи 2011
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.