Deep Learning básico con Keras (Parte 1)

Deep Learning básico con Keras (Parte 1)

El aprendizaje supervisado está ampliamente usado para el entrenamiento en sistemas de visión. En este artículo, veremos unas cuantas nociones de Deep Learning supervisado usando el framework Keras.
Keras es un framework de alto nivel para el aprendizaje, escrito en Python y capaz de correr sobre los frameworks TensorFlowCNTK, o Theano. Fue desarrollado con el objeto de facilitar un proceso de experimentación rápida. Lo que haremos en este experimento es entrenar modelos de clasificación de imágenes. Esto consiste en dada una serie de imágenes etiquetadas, reconocer una imagen y asignarle dicha etiqueta (por ejemplo, si es la foto de un gato, el modelo reconocerá que hay un gato).
En este primer artículo entrenaremos una red neuronal sencilla y, a partir de éste, iremos viendo unos cuantos algoritmos conocidos de deep learning y haremos unas cuantas comparativas. El objetivo de esta serie de artículos es facilitar una guía sencilla de programación en Python usando Keras para entrenar modelos de aprendizaje supervisado para un conjunto de datos concreto. Obviamente, los experimentos están realizados con fines educativos por lo que el proceso de entrenamiento será un proceso rápido y los resultados no estarán depurados.
Enlace al artículo original.

Importando las librerías necesarias

En primer lugar, vamos a importar las librerías necesarias. Importaremos las librerías de numpyTensorFlow (éste será el framework sobre el que correrá Keras), Keras y unas librerías necesarias Scikit LearnPandas, etc.
import numpy as np = 42  
from scipy import misc  
from PIL import Image  
import glob  
import matplotlib.pyplot as plt  
import scipy.misc  
from matplotlib.pyplot import imshow  
%matplotlib inline
from IPython.display import SVG  
import cv2  
import seaborn as sn  
import pandas as pd  
import pickle  
from keras import layers  
from keras.layers import Flatten, Input, Add, Dense, Activation, ZeroPadding2D, BatchNormalization, Flatten, Conv2D, AveragePooling2D, MaxPooling2D, GlobalMaxPooling2D, Dropout  
from keras.models import Sequential, Model, load_model  
from keras.preprocessing import image  
from keras.preprocessing.image import load_img  
from keras.preprocessing.image import img_to_array  
from keras.applications.imagenet_utils import decode_predictions  
from keras.utils import layer_utils, np_utils  
from keras.utils.data_utils import get_file  
from keras.applications.imagenet_utils import preprocess_input  
from keras.utils.vis_utils import model_to_dot  
from keras.utils import plot_model  
from keras.initializers import glorot_uniform  
from keras import losses  
import keras.backend as K  
from keras.callbacks import ModelCheckpoint  
from sklearn.metrics import confusion_matrix, classification_report  
import tensorflow as tf  
Preparando el conjunto de datos
Para el experimento, usaremos el conjunto de datos ampliamente usado CIFAR-100. Este conjunto de datos consta de 600 imágenes por cada clase de un total de 100 clases. Se divide en 500 imágenes para entrenamiento y 100 imágenes para validación por cada clase. Las 100 clases están agrupadas en 20 superclases. Cada imagen tiene una etiqueta "fina" (la clase, de entre las 100, a la que pertenece) y una etiqueta "gruesa" (correspondiente a su superclase).
El framework de Keras incluye el módulo para descargarlo directamente:
from keras.datasets import cifar100

(x_train_original, y_train_original), (x_test_original, y_test_original) = cifar100.load_data(label_mode='fine')
Actualmente, hemos descargado los datasets de entrenamiento y validación. x_train_original y x_test_original son los conjuntos de datos con lás imágenes de entrenamiento y validación respectivamente, mientras que y_train_original y y_test_original son los datasets con las etiquetas.
Veamos la forma de y_train_original:
array([[19], [29], [ 0], ..., [ 3], [ 7], [73]])  
Como se puede ver, se trata de un array donde cada número se corresponde con la etiqueta concreta. Lo primero que hay que hacer es convertir este array en su versión one-hot-encoding (ver wikipedia).
y_train = np_utils.to_categorical(y_train_original, 100)
y_test = np_utils.to_categorical(y_test_original, 100)
El siguiente paso es ver los datos de entrenamiento (xtrainoriginal)
array([[[255, 255, 255],  
        [255, 255, 255],
        [255, 255, 255],
        ...,
        [195, 205, 193],
        [212, 224, 204],
        [182, 194, 167]],

       [[255, 255, 255],
        [254, 254, 254],
        [254, 254, 254],
        ...,
        [170, 176, 150],
        [161, 168, 130],
        [146, 154, 113]],

       [[255, 255, 255],
        [254, 254, 254],
        [255, 255, 255],
        ...,
        [189, 199, 169],
        [166, 178, 130],
        [121, 133,  87]],

       ...,

       [[148, 185,  79],
        [142, 182,  57],
        [140, 179,  60],
        ...,
        [ 30,  17,   1],
        [ 65,  62,  15],
        [ 76,  77,  20]],

       [[122, 157,  66],
        [120, 155,  58],
        [126, 160,  71],
        ...,
        [ 22,  16,   3],
        [ 97, 112,  56],
        [141, 161,  87]],

       [[ 87, 122,  41],
        [ 88, 122,  39],
        [101, 134,  56],
        ...,
        [ 34,  36,  10],
        [105, 133,  59],
        [138, 173,  79]]], dtype=uint8)
Bien, representa la imagen en los 3 canales RGB de 256 píxeles. Vamos a verla:
imgplot = plt.imshow(x_train_original[3])
plt.show()

3ª imagen del dataset de entrenamiento
Lo que haremos a continuación, es normalizar las imágenes. Esto es, dividiremos cada elemento de x_train_original y xtestoriginal por el numero de píxeles, es decir, 255. Con esto obtenemos que el array comprenderá valores de entre 0 y 1. Con esto el entrenamiento suele aportar mejores resultados.
x_train = x_train_original/255  
x_test = x_test_original/255  

Preparando el entorno

El siguiente paso es definir ciertos parámetros sobre el experimento en Keras. Lo primero será especificar a Keras dónde se encuentran los canales. En un array de imágenes, pueden venir como último indice o como el primero. Esto se conoce como canales primero (channels first) o canales al final (channels last). En nuestro caso, vamos a definirlos al final.
K.set_image_data_format('channels_last')  
Lo siguiente que vamos a especificar es la fase del experimento. En este caso, la fase será de entrenamiento:
K.set_learning_phase(1)  

Entrenando una red neuronal sencilla

En primer lugar, vamos a entrenar una red neuronal sencilla. Definimos un procedimiento que nos devuelva una red neuronal:

Esquema de una red neuronal
def create_simple_nn():  
  model = Sequential()
  model.add(Flatten(input_shape=(32, 32, 3), name="Input_layer"))
  model.add(Dense(1000, activation='relu', name="Hidden_layer_1"))
  model.add(Dense(500, activation='relu', name="Hidden_layer_2"))
  model.add(Dense(100, activation='softmax', name="Output_layer"))

  return model
La instrucción Flatten convierte los elementos de la matriz de imagenes de entrada en un array plano. Luego, con la instrucción Dense, añadimos una capa oculta (hidden layer) de la red neuronal. La primera tendrá 1000 nodos, la segunda 500 y la tercera (capa de salida) 100. Para la función de activación usaremos en las capas ocultas ReLu y para la capa de salida SoftMax.
Una vez definido el modelo, lo compilamos especificando la función de optimización, la de coste o pérdida y las métricas que usaremos. En este caso, usaremos la función de optimización de descenso de gradiente estocástico (stochactic gradient descent), la función de pérdida de entropía cruzada (categorical cross entropy) y, para las métricas, accuracy (o tasa de acierto) y mse (media de los errores cuadráticos). Todas éstas funciones ya vienen preimplementadas en Keras.
snn_model = create_simple_nn()  
snn_model.compile(loss='categorical_crossentropy', optimizer='sgd', metrics=['acc', 'mse'])  
Una vez hecho esto, vamos a ver un resumen del modelo creado.
snn_model.summary()

_________________________________________________________________  
Layer (type)                 Output Shape              Param #  
=================================================================
Input_layer (Flatten)        (None, 3072)              0  
_________________________________________________________________  
Hidden_layer_1 (Dense)       (None, 1000)              3073000  
_________________________________________________________________  
Hidden_layer_2 (Dense)       (None, 500)               500500  
_________________________________________________________________  
Output_layer (Dense)         (None, 100)               50100  
=================================================================
Total params: 3,623,600  
Trainable params: 3,623,600  
Non-trainable params: 0  
_________________________________________________________________  
Podemos ver que, para ser un modelo simple de red neuronal, tiene que entrenar más de 3 millones de parámetros. Esta será la razón por la que existe el aprendizaje profundo, ya que para entrenar redes muy complejas se necesitaría entrenar de esta forma grandes cantidades de parámetros.
Ahora sólo queda entrenar, para ello, haremos lo siguiente:
snn = snn_model.fit(x=x_train, y=y_train, batch_size=32, epochs=10, verbose=1, validation_data=(x_test, y_test), shuffle=True)  
Le decimos a Keras que queremos usar para entrenar el dataset imágenes normalizadas de entrenamiento con el array de etiquetas one-hot-encoding. Usaremos batches o bloques de 32 (reduciendo la necesidad de memoria) y daremos 10 vueltas completas (o epochs). Usaremos los datos para validar xtest e ytest. El proceso de entrenamiento lo iremos viendo a continuación hasta terminar. El resultado del entrenamiento se guarda en la variable snn, de la cual, extraeremos el histórico de los datos del entrenamiento:
Train on 50000 samples, validate on 10000 samples  
Epoch 1/10  
50000/50000 [==============================] - 16s 318us/step - loss: 4.1750 - acc: 0.0740 - mean_squared_error: 0.0097 - val_loss: 3.9633 - val_acc: 0.1051 - val_mean_squared_error: 0.0096  
Epoch 2/10  
50000/50000 [==============================] - 15s 301us/step - loss: 3.7919 - acc: 0.1298 - mean_squared_error: 0.0095 - val_loss: 3.7409 - val_acc: 0.1427 - val_mean_squared_error: 0.0094  
Epoch 3/10  
50000/50000 [==============================] - 15s 294us/step - loss: 3.6357 - acc: 0.1579 - mean_squared_error: 0.0093 - val_loss: 3.6429 - val_acc: 0.1525 - val_mean_squared_error: 0.0093  
Epoch 4/10  
 50000/50000 [==============================] - 15s 301us/step - loss: 3.5300 - acc: 0.1758 - mean_squared_error: 0.0092 - val_loss: 3.6055 - val_acc: 0.1626 - val_mean_squared_error: 0.0093
Epoch 5/10  
50000/50000 [==============================] - 15s 300us/step - loss: 3.4461 - acc: 0.1904 - mean_squared_error: 0.0091 - val_loss: 3.5030 - val_acc: 0.1812 - val_mean_squared_error: 0.0092  
Epoch 6/10  
50000/50000 [==============================] - 15s 301us/step - loss: 3.3714 - acc: 0.2039 - mean_squared_error: 0.0090 - val_loss: 3.4600 - val_acc: 0.1912 - val_mean_squared_error: 0.0091  
Epoch 7/10  
 50000/50000 [==============================] - 15s 301us/step - loss: 3.3050 - acc: 0.2153 - mean_squared_error: 0.0089 - val_loss: 3.4329 - val_acc: 0.1938 - val_mean_squared_error: 0.0091
Epoch 8/10  
50000/50000 [==============================] - 15s 300us/step - loss: 3.2464 - acc: 0.2275 - mean_squared_error: 0.0089 - val_loss: 3.3965 - val_acc: 0.2013 - val_mean_squared_error: 0.0090  
Epoch 9/10  
50000/50000 [==============================] - 15s 301us/step - loss: 3.1902 - acc: 0.2361 - mean_squared_error: 0.0088 - val_loss: 3.3371 - val_acc: 0.2133 - val_mean_squared_error: 0.0089  
Epoch 10/10  
 50000/50000 [==============================] - 15s 299us/step - loss: 3.1354 - acc: 0.2484 - mean_squared_error: 0.0087 - val_loss: 3.3233 - val_acc: 0.2154 - val_mean_squared_error: 0.0089
Aunque hemos evaluado durante el entrenamiento, podríamos evaluarlo frente a otro dataset, por lo que expongo a continuación cómo hacerlo en Keras:
evaluation = snn_model.evaluate(x=x_test, y=y_test, batch_size=32, verbose=1)  
evaluation

10000/10000 [==============================] - 1s 127us/step  
[3.323309226989746, 0.2154, 0.008915210169553756]
Veamos las métricas obtenidas para el entrenamiento y validación gráficamente (para ello usamos la librería matplotlib):
plt.figure(0)  
plt.plot(snn.history['acc'],'r')  
plt.plot(snn.history['val_acc'],'g')  
plt.xticks(np.arange(0, 11, 2.0))  
plt.rcParams['figure.figsize'] = (8, 6)  
plt.xlabel("Num of Epochs")  
plt.ylabel("Accuracy")  
plt.title("Training Accuracy vs Validation Accuracy")  
plt.legend(['train','validation'])

plt.figure(1)  
plt.plot(snn.history['loss'],'r')  
plt.plot(snn.history['val_loss'],'g')  
plt.xticks(np.arange(0, 11, 2.0))  
plt.rcParams['figure.figsize'] = (8, 6)  
plt.xlabel("Num of Epochs")  
plt.ylabel("Loss")  
plt.title("Training Loss vs Validation Loss")  
plt.legend(['train','validation'])

plt.show()  


Acierto



Pérdida
Como vemos, a priori no generaliza demasiado bien, ya que hay una diferencia de acierto de un 4% aproximadamente.

Matriz de confusión usando Scikit Learn

Una vez que hemos entrenado el modelo, vamos a ver otras métricas. Para ello, crearemos la matriz de confusión y, a partir de ella, veremos las métricas precissionrecall y F1-score (ver wikipedia).
Vamos a hacer una predicción sobre el dataset de validación y, a partir de ésta, generamos la matriz de confusión y mostramos las métricas mencionadas
anteriormente.
snn_pred = snn_model.predict(x_test, batch_size=32, verbose=1)  
snn_predicted = np.argmax(snn_pred, axis=1)  
Como podemos ver, vamos a dar como predicha el mayor valor de la predicción. Lo normal es dar un valor mínimo o bias que defina un resultado como positivo, pero en este caso, lo vamos a hacer simple.
Con la librería Scikit Learn, generamos la matriz de confusión y la dibujamos (aunque el gráfico no es muy bueno debido al numero de etiquetas).
#Creamos la matriz de confusión
snn_cm = confusion_matrix(np.argmax(y_test, axis=1), snn_predicted)

# Visualizamos la matriz de confusión
snn_df_cm = pd.DataFrame(snn_cm, range(100), range(100))  
plt.figure(figsize = (20,14))  
sn.set(font_scale=1.4) #for label size  
sn.heatmap(snn_df_cm, annot=True, annot_kws={"size": 12}) # font size  
plt.show()  


Matriz de confusión
Y por último, mostramos las métricas:
snn_report = classification_report(np.argmax(y_test, axis=1), snn_predicted)  
print(snn_report)

             precision    recall  f1-score   support

          0       0.47      0.32      0.38       100
          1       0.29      0.34      0.31       100
          2       0.24      0.12      0.16       100
          3       0.14      0.10      0.12       100
          4       0.06      0.02      0.03       100
          5       0.14      0.17      0.16       100
          6       0.19      0.13      0.15       100
          7       0.14      0.26      0.19       100
          8       0.22      0.18      0.20       100
          9       0.23      0.39      0.29       100
         10       0.29      0.02      0.04       100
         11       0.27      0.09      0.14       100
         12       0.34      0.23      0.28       100
         13       0.26      0.16      0.20       100
         14       0.19      0.13      0.15       100
         15       0.16      0.14      0.15       100
         16       0.28      0.19      0.23       100
         17       0.32      0.25      0.28       100
         18       0.18      0.26      0.21       100
         19       0.42      0.08      0.13       100
         20       0.35      0.45      0.40       100
         21       0.27      0.43      0.33       100
         22       0.27      0.18      0.22       100
         23       0.30      0.46      0.37       100
         24       0.49      0.31      0.38       100
         25       0.14      0.10      0.11       100
         26       0.17      0.11      0.13       100
         27       0.06      0.29      0.09       100
         28       0.32      0.37      0.34       100
         29       0.12      0.21      0.15       100
         30       0.50      0.13      0.21       100
         31       0.24      0.04      0.07       100
         32       0.29      0.19      0.23       100
         33       0.18      0.28      0.22       100
         34       0.17      0.03      0.05       100
         35       0.17      0.07      0.10       100
         36       0.21      0.19      0.20       100
         37       0.24      0.06      0.10       100
         38       0.17      0.06      0.09       100
         39       0.12      0.07      0.09       100
         40       0.26      0.23      0.24       100
         41       0.62      0.45      0.52       100
         42       0.10      0.05      0.07       100
         43       0.09      0.44      0.16       100
         44       0.10      0.12      0.11       100
         45       0.20      0.03      0.05       100
         46       0.22      0.19      0.20       100
         47       0.37      0.19      0.25       100
         48       0.14      0.48      0.22       100
         49       0.38      0.11      0.17       100
         50       0.14      0.05      0.07       100
         51       0.16      0.15      0.16       100
         52       0.43      0.60      0.50       100
         53       0.27      0.61      0.37       100
         54       0.48      0.26      0.34       100
         55       0.07      0.01      0.02       100
         56       0.45      0.13      0.20       100
         57       0.10      0.42      0.16       100
         58       0.35      0.17      0.23       100
         59       0.13      0.36      0.19       100
         60       0.40      0.65      0.50       100
         61       0.42      0.34      0.38       100
         62       0.25      0.49      0.33       100
         63       0.31      0.21      0.25       100
         64       0.14      0.03      0.05       100
         65       0.13      0.02      0.03       100
         66       0.00      0.00      0.00       100
         67       0.20      0.35      0.25       100
         68       0.24      0.66      0.35       100
         69       0.26      0.30      0.28       100
         70       0.37      0.22      0.28       100
         71       0.37      0.46      0.41       100
         72       0.11      0.01      0.02       100
         73       0.22      0.22      0.22       100
         74       0.09      0.06      0.07       100
         75       0.27      0.28      0.27       100
         76       0.29      0.38      0.33       100
         77       0.20      0.01      0.02       100
         78       0.19      0.03      0.05       100
         79       0.25      0.02      0.04       100
         80       0.14      0.02      0.04       100
         81       0.13      0.02      0.03       100
         82       0.59      0.50      0.54       100
         83       0.14      0.15      0.14       100
         84       0.18      0.06      0.09       100
         85       0.20      0.52      0.28       100
         86       0.31      0.23      0.26       100
         87       0.21      0.27      0.23       100
         88       0.07      0.02      0.03       100
         89       0.16      0.44      0.24       100
         90       0.20      0.03      0.05       100
         91       0.30      0.34      0.32       100
         92       0.20      0.10      0.13       100
         93       0.18      0.17      0.17       100
         94       0.46      0.25      0.32       100
         95       0.23      0.41      0.29       100
         96       0.24      0.17      0.20       100
         97       0.10      0.16      0.12       100
         98       0.09      0.13      0.11       100
         99       0.39      0.15      0.22       100

avg / total       0.24      0.22      0.20     10000  

Curva ROC (tasas de verdaderos positivos y falsos positivos)

La curva ROC inicialmente es usada en los clasificadores binarios por ser una buena herramienta para enfrentar la tasa de positivos reales contra los falsos positivos.
Vamos a codificar la curva ROC para clasificación multiclase. El código está obtenido del blog de DloLogy, pero se puede obtener de la documentación de Scikit Learn.
from sklearn.datasets import make_classification  
from sklearn.preprocessing import label_binarize  
from scipy import interp  
from itertools import cycle

n_classes = 100

from sklearn.metrics import roc_curve, auc

# Plot linewidth.
lw = 2

# Compute ROC curve and ROC area for each class
fpr = dict()  
tpr = dict()  
roc_auc = dict()  
for i in range(n_classes):  
    fpr[i], tpr[i], _ = roc_curve(y_test[:, i], snn_pred[:, i])
    roc_auc[i] = auc(fpr[i], tpr[i])

# Compute micro-average ROC curve and ROC area
fpr["micro"], tpr["micro"], _ = roc_curve(y_test.ravel(), snn_pred.ravel())  
roc_auc["micro"] = auc(fpr["micro"], tpr["micro"])

# Compute macro-average ROC curve and ROC area

# First aggregate all false positive rates
all_fpr = np.unique(np.concatenate([fpr[i] for i in range(n_classes)]))

# Then interpolate all ROC curves at this points
mean_tpr = np.zeros_like(all_fpr)  
for i in range(n_classes):  
    mean_tpr += interp(all_fpr, fpr[i], tpr[i])

# Finally average it and compute AUC
mean_tpr /= n_classes

fpr["macro"] = all_fpr  
tpr["macro"] = mean_tpr  
roc_auc["macro"] = auc(fpr["macro"], tpr["macro"])

# Plot all ROC curves
plt.figure(1)  
plt.plot(fpr["micro"], tpr["micro"],  
         label='micro-average ROC curve (area = {0:0.2f})'
               ''.format(roc_auc["micro"]),
         color='deeppink', linestyle=':', linewidth=4)

plt.plot(fpr["macro"], tpr["macro"],  
         label='macro-average ROC curve (area = {0:0.2f})'
               ''.format(roc_auc["macro"]),
         color='navy', linestyle=':', linewidth=4)

colors = cycle(['aqua', 'darkorange', 'cornflowerblue'])  
for i, color in zip(range(n_classes-97), colors):  
    plt.plot(fpr[i], tpr[i], color=color, lw=lw,
             label='ROC curve of class {0} (area = {1:0.2f})'
             ''.format(i, roc_auc[i]))

plt.plot([0, 1], [0, 1], 'k--', lw=lw)  
plt.xlim([0.0, 1.0])  
plt.ylim([0.0, 1.05])  
plt.xlabel('False Positive Rate')  
plt.ylabel('True Positive Rate')  
plt.title('Some extension of Receiver operating characteristic to multi-class')  
plt.legend(loc="lower right")  
plt.show()


# Zoom in view of the upper left corner.
plt.figure(2)  
plt.xlim(0, 0.2)  
plt.ylim(0.8, 1)  
plt.plot(fpr["micro"], tpr["micro"],  
         label='micro-average ROC curve (area = {0:0.2f})'
               ''.format(roc_auc["micro"]),
         color='deeppink', linestyle=':', linewidth=4)

plt.plot(fpr["macro"], tpr["macro"],  
         label='macro-average ROC curve (area = {0:0.2f})'
               ''.format(roc_auc["macro"]),
         color='navy', linestyle=':', linewidth=4)

colors = cycle(['aqua', 'darkorange', 'cornflowerblue'])  
for i, color in zip(range(3), colors):  
    plt.plot(fpr[i], tpr[i], color=color, lw=lw,
             label='ROC curve of class {0} (area = {1:0.2f})'
             ''.format(i, roc_auc[i]))

plt.plot([0, 1], [0, 1], 'k--', lw=lw)  
plt.xlabel('False Positive Rate')  
plt.ylabel('True Positive Rate')  
plt.title('Some extension of Receiver operating characteristic to multi-class')  
plt.legend(loc="lower right")  
plt.show()  
El resultado para diez clases se muestra en los siguientes gráficos:


Curva ROC para 3 clases


Zoom de la Curva ROC para 3 clases
Vamos a ver algunos resultados:
imgplot = plt.imshow(x_train_original[0])  
plt.show()  
print('class for image 1: ' + str(np.argmax(y_test[0])))  
print('predicted:         ' + str(snn_predicted[0]))  

Una vaca?
class for image 1: 49  
predicted:         12  
imgplot = plt.imshow(x_train_original[3])  
plt.show()  
print('class for image 3: ' + str(np.argmax(y_test[3])))  
print('predicted:         ' + str(snn_predicted[3]))  


Un hombre
class for image 3: 51  
predicted:         51  
Finalmente, salvaremos los datos del histórico de entrenamiento para compararlos con otros modelos:
#Histórico
with open(path_base + '/simplenn_history.txt', 'wb') as file_pi:  
  pickle.dump(snn.history, file_pi)

Conclusión sobre el primer experimento

Aunque no está mal para diez epochs, vemos que las gráficas no van a mejorar mucho mas, pues estan empezando a ponerse horizontales, lo que indica que aumentar mucho más las vueltas de entrenamiento no van a mejorar el aprendizaje. Si bien la curva ROC da una buena tasa de positivos reales contra falsos positivos (es decir, cuando especifica una clase, no suele dar un falso positivo), el nivel de acierto es muy bajo para las métricas accuracyrecall y precission.
El siguiente artículo presentará una red convolutiva para entrenar el mismo conjunto de datos con exáctamente las mismas métricas y funciones de pérdida y optimización.
Y si te ha gustado, ¡síguenos en Twitter para estar al día de nuevos posts!
¡Hasta la próxima!
SHARE

Oscar perez

Arquitecto especialista en gestion de proyectos si necesitas desarrollar algun proyecto en Bogota contactame en el 3006825874 o visita mi pagina en www.arquitectobogota.tk

  • Image
  • Image
  • Image
  • Image
  • Image
    Blogger Comment
    Facebook Comment

0 comentarios:

Publicar un comentario