Accessibility Tools

- Le blog participatif de bioinformatique francophone depuis 2012 -

Intelligence Artificielle, Machine Learning, Deep-Learning, quid du Data-Scientist

DNA matrix gene­tics | The­Di­gi­ta­lAr­tist

Intel­li­gence arti­fi­cielle (IA), Machine lear­ning (Appren­tis­sage machine, pour les fran­co­phones), Deep-lear­ning (Appren­tis­sage pro­fond), autant de termes si étran­gers et fami­liers à la fois… Com­ment se retrou­ver dans cette jungle de termes tech­niques ?

Com­men­çons par défi­nir ce qu'est l'IA. Base de science-fic­tion pour cer­tains, source d'inquiétudes pour d'autres, nous abor­de­rons l'IA d'un point de vue tech­nique. Ins­pi­rée de la bio­lo­gie et des sciences cog­ni­tives, sur fon­de­ments mathé­ma­tiques, l'intelligence arti­fi­cielle se défi­nit comme un ensemble d'algorithmes visant à repro­duire les déci­sions prises par un être humain dans le but d'accomplir une tâches spé­ci­fique.
Qui dit humain, dit appren­tis­sage per­cep­tuel, orga­ni­sa­tion de la mémoire et le rai­son­ne­ment cri­tique. En effet, tout algo­rithme d'IA néces­si­te­ra la connais­sance de l'Homme, aus­si bien dans la pré­pa­ra­tion, l'étiquetage des don­nées, et leur inter­pré­ta­tion.

L'IA oui, mais pour quoi faire ?

Les tâches accom­plis­sables par l'IA sont aus­si variées que la défi­ni­tion de l'IA en elle-même, et que le nombre d'approches pour résoudre un pro­blème don­né. Les tâches les plus com­mu­né­ment réso­lues à l'aide de l'intelligence arti­fi­cielle sont : la clas­si­fi­ca­tion (binaire, mul­ti-classes), la régres­sion, la seg­men­ta­tion d'images (iden­ti­fi­ca­tion des dif­fé­rentes com­po­santes de l'image de manière auto­ma­ti­sée), le débrui­tage de don­nées, la détec­tion d'objets, le trai­te­ment du lan­gage natu­rel, etc. (liste non exhaus­tive). Pour un peu plus de géné­ra­li­tés sur l'apprentissage pro­fond et ses appli­ca­tion, c'est ici

Rele­vons main­te­nant nos manches et abor­dons notre pre­mier pro­jet d'IA.

Définition de la problématique

Pour cette intro­duc­tion, abor­dons une pro­blé­ma­tique rela­ti­ve­ment simple, sur des don­nées simples. Nous allons aujourd'hui nous inté­res­ser à la clas­si­fi­ca­tion de séquences d'ADN, afin de déter­mi­ner si ces séquences sont pro­mo­trices ou non (clas­si­fi­ca­tion binaire). L'idée est donc de pro­po­ser à l'entrée de notre réseau une séquence de nucléo­tides pour obte­nir en sor­tie une classe 0 ou 1, décri­vant res­pec­ti­ve­ment les carac­tères non-pro­mo­teurs et pro­mo­teurs de notre séquence (0 la séquence n'est pas pro­mo­trice, 1 elle l'est). Les don­nées seront décou­pées en deux fichiers : l'un conte­nant les séquences pro­mo­trices, le second conte­nant les séquences non pro­mo­trices. Don­nées dis­po­nibles ici, le note­book du code est dis­po­nible .

Préparation des données

Pour ce pro­jet bien que peu com­plexe, nous uti­li­se­rons Google Colab afin de faci­li­ter l'accès aux don­nées et béné­fi­cier de res­sources suf­fi­santes. Bien enten­du, la démarche est repro­duc­tible en local, et avec peut être même plus de per­for­mances.

Com­men­çons "mon­ter" notre Drive, afin de pou­voir accé­der aux don­nées, défi­nis­sons-en les che­mins, et impor­tons les librai­ries néces­saires.

from google.colab import drive
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import  tensorflow as tf
import keras
from keras.layers import Dense, Dropout, Flatten, Conv1D, MaxPooling1D
from keras import regularizers
from keras.callbacks import EarlyStopping, ModelCheckpoint
from keras.wrappers.scikit_learn import KerasClassifier
from sklearn.model_selection import train_test_split

# montage du Drive
drive.mount('/content/drive')

# definition des chemins d'acces aux fichiers

work_folder = "/content/drive/MyDrive/Promoters_classification/"
non_promoters_set = work_folder + "NonPromoterSequence.txt"
promoter_set = work_folder + "PromoterSequence.txt"

Les don­nées n'ayant pas un for­mat "stan­dard" pour les librai­ries clas­siques, il va être néces­saire de les for­ma­ter cor­rec­te­ment. Remar­quons qu'il s'agit ici de don­nées type fas­ta, le sépa­ra­teur sera donc un che­vron '>'. Un article de bioin­fo-fr sur le for­mat fas­ta à cette adresse.

Nous sou­hai­tons nous rame­ner à un tableau à deux colonnes : séquence, label. De plus, nos don­nées sont consi­gnées dans deux fichiers dif­fé­rents qu'il va fal­loir assem­bler. Réa­li­sons donc cela.

# Chargement nettoyage et labelisation des données de sequences non promotrices
df_non_promoters = pd.read_csv(non_promoters_set, sep = '>', )
df_non_promoters.dropna(subset=['Unnamed: 0'], how='all', inplace=True)
df_non_promoters.reset_index(inplace = True)
df_non_promoters.drop(['EP 1 (+) mt:CoI_1; range -400 to -100.', 'index'], axis = 1, inplace=True) #data cleaning after error found
df_non_promoters.rename(columns={'Unnamed: 0': "sequence"}, inplace = True)
df_non_promoters['label'] = 0
# Chargement, nettoyage et labelisation des données de sequences promotrices
df_promoters = pd.read_csv(promoter_set, sep = '>', )
df_promoters.dropna(subset=['Unnamed: 0'], how='all', inplace=True)
df_promoters.reset_index(inplace = True)
df_promoters.drop(['EP 1 (+) mt:CoI_1; range -100 to 200.', 'index'], axis = 1, inplace=True) #data cleaning after error found
df_promoters.rename(columns={'Unnamed: 0': "sequence"}, inplace = True)
df_promoters['label'] = 1
# Concatenation des deux tableaux
df = pd.concat([df_non_promoters, df_promoters], axis = 0 )
print(f"Shape of the full dataset: {df.shape}")
Shape of the full dataset: (22600, 2)
                                            sequence  label
0  TAATTACATTATTTTTTTATTTACGAATTTGTTATTCCGCTTTTAT...      0
1  ATTTTTACAAGAACAAGACATTTAACTTTAACTTTATCTTTAGCTT...      0
2  AGAGATAGGTGGGTCTGTAACACTCGAATCAAAAACAATATTAAGA...      0
3  TATGTATATAGAGATAGGCGTTGCCAATAACTTTTGCGTTTTTTGC...      0
4  AGAAATAATAGCTAGAGCAAAAAACAGCTTAGAACGGCTGATGCTC...      0

Nous nous retrou­vons donc avec un tableau à deux colonnes conte­nant 22600 séquences et leurs labels. Comme toute bonne ana­lyse de don­nées, il va être néces­saire de net­toyer ces der­nières. Dans notre cas, il s'agira de reti­rer les séquences pré­sen­tant des nucléo­tides mar­qués comme "N".

nb_sequences_to_drop = 0
rows_indexes_to_drop = list()
for idx, seq in enumerate(df['sequence']):
    if 'N' in seq:
      nb_sequences_to_drop +=1
      # display(df.loc[df['sequence'] == seq])
      rows_indexes_to_drop.append(idx)
print(f"Number of sequence to be dropped : {nb_sequences_to_drop}")
print(f"Rows to be dropped : {rows_indexes_to_drop}")
print(f"Shape of the data before dropping sequences containing Ns : {df.shape}")
df.drop(rows_indexes_to_drop, inplace = True)
print(f"Shape of the data after dropping sequences containing Ns : {df.shape}")
Number of sequences to be dropped : 1
Rows to be dropped : [1822]
Shape of the data before dropping sequences containing Ns : (22600, 2)
Shape of the data after dropping sequences containing Ns : (22598, 2)

Nous allons construire un réseau à convo­lu­tion, basé sur des don­nées tex­tuelles. Ces don­nées, n'étant pas direc­te­ment trai­tables par ce type de réseau, il va fal­loir en chan­ger la repré­sen­ta­tion. Une façon clas­sique de faire est d'utiliser la stra­té­gie de "one-hot enco­ding".

Enco­ding des séquences d'ADN

L'idée est de carac­té­ri­ser (pour notre pro­blé­ma­tique) cha­cune des bases de la séquence par un qua­dru­plet de valeurs étant soit 0, soit 1, sachant que seule une des valeurs peut être à 1. Ain­si, les 4 bases sont enco­dées comme : A : {1, 0, 0, 0}, T : {0, 1, 0, 0} , C : {0, 0, 1, 0} et G : {0, 0, 0, 1}. Dans notre cas, nos séquences étant de lon­gueur 301, la trans­for­ma­tion don­ne­ra donc nais­sance pour une matrice de taille 4 x 301. A l'échelle du jeu de don­nées, les dimen­sions seront de 22598 x 301 x 4. Réa­li­sons donc notre enco­dage.

sequence = list(df.loc[:, 'sequence'])
encoded_list = []
def encode_seq(s):
    Encode = {'A':[1,0,0,0],'T':[0,1,0,0],'C':[0,0,1,0],'G':[0,0,0,1]}
    return [Encode[x] for x in s]
for i in sequence:
    x = encode_seq(i)
    encoded_list.append(x)
X = np.array(encoded_list)
print(f" Shape of one-hot encoded sequences: {X.shape}")
Shape of one-hot encoded sequences: (22598, 301, 4)

Nos don­nées sont main­te­nant presque prêtes. Il va s'agir de sépa­rer les séquences trans­for­mées de leurs labels.

X = X.reshape(X.shape[0],301, 4, 1) # Tensor containing one-hot encoded sequences wich are promotors or not
y = df['label'] # Tensor containing labels for each sequence (0 : sequence is nt a promotor, 1 : sequence is a promotor)
print(y.value_counts())
print(f"Shape of tensor containing sequences one-hot encoded {X.shape}n")
print(f"Shape of tensor containing labels relatives to the sequences : {y.shape}")
if(X.shape[0] != y.shape[0]):
  raise ValueError('Sequence tensor and lebels tensor need to have corresponding shapes')

Préparation des jeux de données d'entrainement, de test et de validation

Il est désor­mais néces­saire de sépa­rer nos don­nées en trois sous-ensembles : entrai­ne­ment, test et vali­da­tion. Les don­nées d'entrainement vont per­mettre au réseau d'ajuster ses poids en fonc­tion de l'erreur entre la pré­dic­tion du type de séquence, pro­mo­trice ou non, les don­nées de test d'évaluer les per­for­mances de l'entrainement et enfin les don­nées de vali­da­tion d'obtenir les per­for­mances du modèles une fois entrai­né. Les trois jeux de don­nées doivent être indé­pen­dants les uns des autres afin de garan­tir de ne pas ren­con­trer un exemple de séquence déjà ren­con­tré à l'entrainement. Il est éga­le­ment néces­saire de s'assurer (si pos­sible) que dans cha­cun des sets, les dif­fé­rentes classes soient repré­sen­tées en pro­por­tions égales. Pré­pa­rons nos jeux de don­nées.

# On redimensionne nos données afin qu'elles puissent être traitées par le réseau
X = X.reshape(X.shape[0],301, 4, 1) # Tensor containing one-hot encoded sequences wich are promotors or not
y = df['label'] # Tensor containing labels for each sequence (0 : sequence is nt a promotor, 1 : sequence is a promotor)
print(y.value_counts())
print(f"Shape of tensor containing sequences one-hot encoded {X.shape}n")
print(f"Shape of tensor containing labels relatives to the sequences : {y.shape}")
0    11299
1    11299
Name: label, dtype: int64
Shape of tensor containing sequences one-hot encoded (22598, 301, 4, 1)
Shape of tensor containing labels relatives to the sequences : (22598,)

On s'assure que nous dis­po­sons bien du même nombre de séquences que de labels. C'est le cas, divi­sons alors nos don­nées en res­pec­tant les strates.

X_train, X_test, y_train, y_test = train_test_split(X, y, random_state = 42, stratify = y, shuffle = True, test_size = 0.2) # Split data into train and test with respect of class proportions
X_test, X_validation, y_test, y_validation = train_test_split(X_test, y_test, random_state = 42, stratify = y_test, shuffle = True, test_size = 0.5) # Split data into test and validation with respect of class proportions
print(y_train.value_counts())
print(y_test.value_counts())
print(y_validation.value_counts())
0    9039
1    9039
Name: label, dtype: int64
0    1130
1    1130
Name: label, dtype: int64
1    1130
0    1130
Name: label, dtype: int64

Construction du modèle

Afin de répondre à notre ques­tion, nous allons construire un modèle de type réseau convo­lu­tif peu pro­fond, avec une couche de sor­tie ne dis­po­sant que d'un seul neu­rone qui indique que la séquence est de type pro­mo­teur s'il est acti­vé. Pour en savoir plus sur les réseaux de convo­lu­tion, je vous recom­mande cette vidéo.

L'apprentissage un problème d'optimisation ?

Les pro­blé­ma­tiques d'apprentissage, qu'il soit pro­fond ou machine, peuvent être défi­nies comme des pro­blé­ma­tiques d'optimisation. Il s'agit donc de construire un sys­tème, ou modèle au départ "naïf" que nous allons entraî­ner. Dans notre cas il s'agira de rendre de plus en plus per­for­mant notre modèle en l’entraînant à clas­si­fier cor­rec­te­ment les séquences pro­mo­trices des séquences non pro­mo­trices. Pour cela nous allons deman­der au réseau de clas­ser à plu­sieurs reprises un ensemble d'exemples par lots (ou batches) et éva­luer une erreur, laquelle va nous ser­vir à ajus­ter les coef­fi­cients des noyaux de convo­lu­tions jusqu'à atteindre un opti­mum.
Cet opti­mum cor­res­pond au mini­mum glo­bal de la fonc­tion de coût éva­luant la quan­ti­té d'erreur réa­li­sées dans les tâches de clas­si­fi­ca­tion du modèle. On ajus­te­ra alors ces poids en fonc­tion de l'intensité de cette erreur : plus cette der­nière sera éle­vée, plus les coef­fi­cients seront modi­fiés, à l'inverse moins l'erreur sera grande moins les coef­fi­cients seront modi­fiés. Cette erreur est éva­luée en cal­cu­lant le gra­dient de la fonc­tion d'erreur, ou plus for­mel­le­ment la déri­vée par­tielle en chaque neu­rone du réseau en fonc­tion du neu­rone. Cette même erreur est pon­dé­rée par le taux d'apprentissage (lear­ning rate) lequel pon­dère la force de modi­fi­ca­tion des poids. Ain­si plus le taux d'apprentissage sera faible, plus l'entrainement sera lent, mais plus il aura de chance de conver­ger vers un opti­mum glo­bal.

Comment définir la quantité d'erreur

La quan­ti­té d'erreur est éva­luée par la fonc­tion de coût (ou Loss). Cette der­nière rend compte du nombre de classes mal pré­dites lors de l'évaluation d'un ensemble de séquences de l'entrainement. Dépen­dam­ment des pro­blé­ma­tiques aux­quelles il faut répondre, les fonc­tions de coût dif­fèrent notam­ment par le nombre de classes pos­sibles, les liens qui existent éven­tuel­le­ment entre ces classes.

La quan­ti­té d'erreur est éva­luée à chaque pré­dic­tion de lots de séquences. La déri­vée par­tielle de cette fonc­tion d'erreur sera alors cal­cu­lée vis à vis de cha­cun des para­mètres du réseau, afin d'ajuster ces der­niers en sens "inverse" de cette déri­vée. Ain­si lorsque la quan­ti­té d'erreurs se sta­bi­lise, ou atteint des valeurs très faibles l'ajustement des para­mètres est moindre et l'apprentissage pour­rait se ter­mi­ner.

Dans les exemples ci-après la fonc­tion de coût uti­li­sée sera la "Bina­ry Cross-Entro­py" ren­dant compte de l'erreur de clas­si­fi­ca­tion dans un pro­blème d'optimisation à deux classes.

Vous avez dit convolution ?

La convo­lu­tion est une opé­ra­tion mathé­ma­tique sur les matrices. Il s'agit de "mettre en cor­res­pon­dance" une matrice repré­sen­tant les don­nées, avec une matrice dite "noyau de convo­lu­tion" (ou ker­nel). La matrice résul­tante est le pro­duit matri­ciel entre la matrice de don­nées et le ker­nel (à laquelle on ajoute par­fois une matrice conte­nant le poids des biais de chaque neu­rone).

Exemple de convo­lu­tion

En se basant sur la pre­mière opé­ra­tion de convo­lu­tion de l'illustration pré­cé­dente nous aurions :

R_0 = sum_{i=0}^{15}x_i w_i

On réa­lise cette opé­ra­tion de convo­lu­tion plu­sieurs fois en fai­sant glis­ser le noyau (en rouge) le long de la séquence enco­dée de dimen­sion 301 x4. Nous uti­li­se­rons ici un pad­ding de type valid, qui n'autorise la convo­lu­tion que dans le cas ou le "recou­vre­ment" de lasé­quence par le noyau est com­plet. Ain­si le nombre de convo­lu­tions, et par consé­quent la taille de la repré­sen­ta­tion de sor­tie, seront limi­tés à 298. Le filtre f0 glis­se­ra donc d'un pas de 1 sur la séquence et aura 298 posi­tions don­nant le à autant d'opérations de convo­lu­tions. Fina­le­ment pour ce filtre, la dimen­sion de sor­tie sera de 298x1.

Et nous répé­tons la même opé­ra­tion pour les 26 autres filtres de f1 à f26. Nous obte­nons donc en sor­tie de cette couche de convo­lu­tion une repré­sen­ta­tion de dimen­sion 298x1x27.

Ce résul­tat de convo­lu­tion va ensuite subir une opé­ra­tion d'activation, qui revient à recher­cher l'image de cha­cun des élé­ments de la matrice résul­tant de la convo­lu­tion, par la fonc­tion d'activation (ici ReLU pour "Rec­ti­fied Linear Unit). Cette opé­ra­tion vise à mettre en avant les valeurs/​éléments les plus per­ti­nents de la nou­velle repré­sen­ta­tion après convo­lu­tion qui seront trans­mises aux pro­chaines couches du réseau.

Pool !

Aux couches de convo­lu­tions suc­cèdent les couches de poo­ling. Ces couches visent à sous-échan­tillon­ner les résul­tats des couches convo­lu­tion­nelles afin de ne gar­der que l'information per­ti­nente de cette repré­sen­ta­tion, mais aus­si de dimi­nuer la taille de la repré­sen­ta­tion de la séquence ini­tiale au tra­vers du réseau.

Il existe plu­sieurs méthodes pour sous-échan­tillon­ner les résul­tats de convo­lu­tion. L'idée géné­rale est de sélec­tion­ner par­mi un ensemble plus ou moins grand de valeurs conti­guës de la repré­sen­ta­tion post-convo­lu­tion. Il convient tout d'abords de défi­nir la taille de cette fenêtre de recherche. Dans l'exemple ci-des­sous, cette taille sera de 3. Puis il s'agit de choi­sir la fonc­tion ou méthode per­met­tant d'extraire une unique valeur de cette fenêtre.

Ce choix peut être la moyenne des valeurs de la fenêtre, la médiane, le mini­mum, ou encore dans notre cas le maxi­mum.

Illus­tra­tion du Max Poo­ling

Ain­si dans notre cas, nous pro­cé­de­rons à l'extraction de valeurs d'intérêts dans une fenêtre glis­sant le long du vec­teur résul­tat de la convo­lu­tion, avec une fenêtre de taille 3. Nous uti­li­se­rons la fonc­tion max, c'est à dire que nous ne retien­dront que la valeur maxi­male pour une fenêtre de 3 valeurs conti­guës, avant de répé­ter l'opération aux trois sui­vantes. Avec plus de for­ma­lisme mathé­ma­tique :

R_0' = max(R0, R1, R2)

Ain­si après sous-échan­tillon­nage par une fenêtre de taille 3, les dimen­sions de nos 27 repré­sen­ta­tions post-convo­lu­tions seront de divi­sées par 3.

La notion de champs récepteurs

Comme nous l'avions évo­qué en intro­duc­tion, les réseaux de neu­rones s’inspirent de la Bio­lo­gie. Comme nous le savons, dans le sys­tème ner­veux, les neu­rones sont connec­tés en cas­cade. Cer­taines neu­rones reçoivent l'information pré-trai­tées via les axones de leurs pré­dé­ces­seurs au niveau du soma. Ain­si pour une neu­rone don­née, le signal reçu com­pile l’information trans­mise par ses pré­dé­ces­seurs, qui eux-mêmes ont poten­tiel­le­ment reçu un conden­sé de l'information obte­nue par le sys­tème per­cep­tif.

Le paral­lèle est pos­sible avec les réseaux convo­lu­tifs. Le pre­mier bloc convo­lu­tif (couche de convo­lu­tion + acti­va­tion + poo­ling) voit l'intégralité de la séquence en entrée. Pour­tant comme nous venons de le voir, la sor­tie de ce pre­mier bloc est de dimen­sion moindre que la séquence d'entrée, et ne porte plus de sens bio­lo­gique en tant que tel. L'information trans­mise est un conden­sé de la séquence ini­tiale.

Repré­sen­ta­tion d'un champ récep­teur

Si l'on exa­mine l'exemple ci-des­sus, la séquence est de taille 14. En réa­li­sant une convo­lu­tion de noyau de taille 4, la repré­sen­ta­tion de sor­tie est de taille 11. Puis on applique un sous-échan­tillon­nage avec une fenêtre de taille 3 don­nant alors une repré­sen­ta­tion en sor­tie du pre­mier bloc convo­lu­tif de taille 4. Au bloc convo­lu­tif sui­vant la convo­lu­tion de cette repré­sen­ta­tion par un noyau de taille 4 donne lieu à une nou­velle repré­sen­ta­tion de taille 1, qui après poo­ling avec une fenêtre de taille 3 donne en sor­tie une repré­sen­ta­tion de taille 1.

Le rai­son­ne­ment est le même à mesure que les couches se suc­cèdent, les repré­sen­ta­tions dimi­nuent de taille, et les neu­rones en aval d'une couche voient de plus en plus "large" dans les don­nées d'entrée. il est à noter que les ker­nels de convo­lu­tion, une fois leurs valeurs ajus­tées en fin d'entrainement, per­mettent alors d'extraire de la séquence en entrée les carac­té­ris­tiques clés dans le choix de la classe en sor­tie du réseau. Par ailleurs, plus les couches seront pro­fondes dans le réseau, plus les carac­té­ris­tiques extraites seront com­plexes.

Première tentative

Dans cette pre­mière expé­rience, nous allons uti­li­ser la des­cente de gra­dient sto­chas­tique, qui est un opti­mi­seur de base.

model = tf.keras.Sequential()
model.add(tf.keras.layers.Conv1D(filters = 27, kernel_size = 4, activation = 'relu', padding = "valid", input_shape = (X_train.shape[1], X_train.shape[2])))
model.add(tf.keras.layers.MaxPool1D(pool_size=3, strides=None, padding='valid'))
model.add(tf.keras.layers.Conv1D(filters = 14, kernel_size = 4, activation = 'relu'))
model.add(tf.keras.layers.MaxPool1D(pool_size= 2, strides = None, padding = 'valid'))
model.add(tf.keras.layers.Conv1D(filters = 7, kernel_size = 4, activation = 'relu'))
model.add(tf.keras.layers.MaxPool1D(pool_size= 2, strides = None, padding = 'valid'))
model.add(tf.keras.layers.Flatten())
model.add(tf.keras.layers.Dense(units = 1, activation = 'sigmoid'))
model.summary()
model.compile(loss='binary_crossentropy',
              optimizer='sgd',
              metrics=['accuracy'])
# On definit un arret premature de l'entrainement en cas d'amelioration non notable apres plusieurs phases pour eviter le sur-entrainement 
early_stop = keras.callbacks.EarlyStopping(monitor = 'val_accuracy', min_delta = 0.0005, patience=8, 
                                           restore_best_weights=True )
# On definit un monitoring les performances du modele et on lance l'entrainement
history = model.fit(X_train, y_train, batch_size = 128, validation_data=(X_test, y_test), 
                        epochs=115)
# On plot les résultats de l'entrainement
plt.figure(figsize = (10,4))
plt.subplot(121)
plt.plot(history.epoch, history.history["loss"], 'g', label='Training loss')
plt.plot(history.epoch, history.history["val_loss"], 'b', label='Validation loss')
plt.title('Training loss')
plt.xlabel('Epochs')
plt.ylabel('Loss')
plt.legend()
plt.subplot(122)
plt.plot(history.epoch, history.history["accuracy"], 'r', label='Training accuracy')
plt.plot(history.epoch, history.history["val_accuracy"], 'orange', label='Validation accuracy')
plt.title('Training accuracy')
plt.xlabel('Epochs')
plt.ylabel('Accuracy')
plt.legend()
plt.savefig(work_folder + "base_model_deep_no_dropout_sgd.png")
plt.show()

Nous pou­vons obser­ver plu­sieurs choses sur nos courbes d'apprentissage. Pre­miè­re­ment, que ce soit en entrai­ne­ment ou en vali­da­tion, ni l'erreur, ni la per­for­mance n'atteignent de pla­teau. Il sem­ble­rait donc que mal­gré les 150 passes (epochs) d'apprentissage, l'optimum ne soit pas atteint.

NB : Une passe (ou epoch en anglais) cor­res­pond à une ses­sion d'entrainement du modèle. A la fin de la pre­mière epoch, l'ensemble des exemples d'apprentissage ont été vus une fois, à la fin de l'époque n l'ensemble de ces mêmes exemples ont été vus n fois par le réseau.

Par ailleurs, nous pou­vons obser­ver que les per­for­mances en vali­da­tion, donc sur des exemples non-obser­vés en entrai­ne­ment, est moins bonne.

Notre modèle ne semble donc pas assez entraî­né d'une part, et montre des dif­fi­cul­tés à géné­ra­li­ser.

Résul­tats d'entrainement avec un opti­mi­seur SGD et sans dro­pout
# Evaluation finale du modele
loss, acc = model.evaluate(X_validation, y_validation, verbose=2)
print("Untrained model, accuracy: {:5.2f}%".format(100 * acc))
71/71 - 0s - loss: 0.3394 - accuracy: 0.8575 - 404ms/epoch - 6ms/step
Untrained model, accuracy: 85.75%)

Bien que les résul­tats ne soient pas par­fait, le modèle pré­sente 85.75% de jus­tesse ce qui est déjà très bon. Voyons com­ment rendre notre modèle plus per­for­mant à l'aide d'un autre opti­mi­seur.

L'optimiseur ADAM

Chan­geons l'optimiseur SGD pour un opti­mi­seur de type ADAM. Cet opti­mi­seur se base sur les fon­de­ments du SGD, mais uti­lise les moments de pre­mier et second ordre du gra­dient de la fonc­tion d'erreur. Cela per­met à l'optimiseur d'avoir une "mémoire" de l'entrainement pré­cé­dent, et donc de la force de la pré­cé­dente modi­fi­ca­tion des coef­fi­cient des noyaux de convo­lu­tion.

Par ailleurs ce type d'optimiseur intègre un taux d'apprentissage adap­ta­tif, qui va per­mettre de modi­fier for­te­ment les coef­fi­cient des noyaux de convo­lu­tion au début de l'entrainement, et moins for­te­ment au fur et mesure des phases d'entrainement.

Ain­si notre modèle va apprendre en tenant compte de plus que l'entrainement pré­cé­dent, et plus fine­ment au fil des phases.

model_adam = tf.keras.Sequential()
model_adam.add(tf.keras.layers.Conv1D(filters = 27, kernel_size = 4, activation = 'relu', padding = "valid", input_shape = (X_train.shape[1], X_train.shape[2])))
model_adam.add(tf.keras.layers.MaxPool1D(pool_size=3, strides=None, padding='valid'))
model_adam.add(tf.keras.layers.Conv1D(filters = 14, kernel_size = 4, activation = 'relu'))
model_adam.add(tf.keras.layers.MaxPool1D(pool_size= 2, strides = None, padding = 'valid'))
model_adam.add(tf.keras.layers.Conv1D(filters = 7, kernel_size = 4, activation = 'relu'))
model_adam.add(tf.keras.layers.MaxPool1D(pool_size= 2, strides = None, padding = 'valid'))
model_adam.add(tf.keras.layers.Flatten())
model_adam.add(tf.keras.layers.Dense(units = 1, activation = 'sigmoid'))
model_adam.summary()
model_adam.compile(loss='binary_crossentropy',
              optimizer='adam',
              metrics=['accuracy'])
# On defini un arret premature de l'entrainement en cas d'amelioration non notable apres plusieurs phases pour eviter le sur-entrainement 
early_stop = keras.callbacks.EarlyStopping(monitor = 'val_accuracy', min_delta = 0.0005, patience=8, 
                                           restore_best_weights=True )
# On defini un monitoring les performances du modele et on lance l'entrainement
history_adam = model_adam.fit(X_train, y_train, batch_size = 128, validation_data=(X_test, y_test), 
                        epochs=115)
# On plot les résultats de l'entrainement
plt.figure(figsize = (10,4))
plt.subplot(121)
plt.plot(history_adam.epoch, history_adam.history["loss"], 'g', label='Training loss')
plt.plot(history_adam.epoch, history_adam.history["val_loss"], 'b', label='Validation loss')
plt.title('Training loss')
plt.xlabel('Epochs')
plt.ylabel('Loss')
plt.legend()
plt.subplot(122)
plt.plot(history_adam.epoch, history_adam.history["accuracy"], 'r', label='Training accuracy')
plt.plot(history_adam.epoch, history_adam.history["val_accuracy"], 'orange', label='Validation accuracy')
plt.title('Training accuracy')
plt.xlabel('Epochs')
plt.ylabel('Accuracy')
plt.legend()
plt.savefig(work_folder + "base_model_deep_no_dropout_adam.png")
plt.show()

L'optimiseur ADAM est recon­nu pour conver­ger plus rapi­de­ment (atteindre le mini­mum glo­bal de la fonc­tion de coût), mais en revanche, avoir du mal à géné­ra­li­ser.

Résul­tats d'entrainement avec un opti­mi­seur ADAM et sans dro­pout
# Evaluation finale du modele
loss, acc = model_adam.evaluate(X_validation, y_validation, verbose=2)
print("Untrained model, accuracy: {:5.2f}%".format(100 * acc))
71/71 - 0s - loss: 0.3810 - accuracy: 0.8509 - 496ms/epoch - 7ms/step
Untrained model, accuracy: 85.09%

Nous pou­vons obser­ver que les per­for­mances du modèle se main­tiennent aux alen­tours de 85% de jus­tesse. Néan­moins, les courbes d'erreur et de jus­tesse semblent avoir un pla­teau. L'entrainement semble avoir été mené jusqu'au bout. Néan­moins nous obser­vons d'une part que l'erreur est plus éle­vée (et res­pec­ti­ve­ment la jus­tesse moins éle­vée) lors de la vali­da­tion que lors de l'entrainement, une fois de plus le modèle géné­ra­lise moins bien sur des exemples incon­nus. D'autre part, on observe une hausse de l'erreur en vali­da­tion au delà de la 60ème passe. Ceci signe un sur-appren­tis­sage du modèle, lequel semble très bien apprendre sur sa base d'apprentissage, si bien que lorsque des exemples nou­veau se pré­sentent, les per­for­mances chutent.

Voyons à pré­sent com­ment remé­dier à ce pro­blème de sur entrai­ne­ment.

Le dropout

Afin de pré­ve­nir le sur appren­tis­sage, une méthode consiste à accor­der la pos­si­bi­li­té au réseau de décon­nec­ter tem­po­rai­re­ment cer­tains de ses neu­rones. Ain­si après esti­ma­tion de l'erreur, une por­tion des coef­fi­cients des noyaux de convo­lu­tions ne seront pas ajus­tés. C'est ce que l'on appelle le dro­pout.

Afin de para­mé­trer cette décon­nexion, nous allons fixer le para­mètre p com­pris entre 0 et 1, qui indique le pour­cen­tage de neu­rones à décon­nec­ter lors d'une phase d'ajustement des poids du réseau. Une valeur stan­dard est 0.2. Ain­si durant une phase d'apprentissage don­née, 20% des neu­rones du réseau, choi­sis de manière aléa­toire ne seront pas ajus­tés. Lors de la phase sui­vante, 20% de neu­rones seront à nou­veau décon­nec­tés, mais pas néces­sai­re­ment les mêmes.

model_adam_dropout = tf.keras.Sequential()
model_adam_dropout.add(tf.keras.layers.Conv1D(filters = 27, kernel_size = 4, activation = 'relu', padding = "valid", input_shape = (X_train.shape[1], X_train.shape[2])))
model_adam_dropout.add(tf.keras.layers.MaxPool1D(pool_size=3, strides=None, padding='valid'))
model_adam_dropout.add(tf.keras.layers.Dropout(0.2))
model_adam_dropout.add(tf.keras.layers.Conv1D(filters = 14, kernel_size = 4, activation = 'relu'))
model_adam_dropout.add(tf.keras.layers.MaxPool1D(pool_size= 2, strides = None, padding = 'valid'))
model_adam_dropout.add(tf.keras.layers.Dropout(0.2))
model_adam_dropout.add(tf.keras.layers.Conv1D(filters = 7, kernel_size = 4, activation = 'relu'))
model_adam_dropout.add(tf.keras.layers.MaxPool1D(pool_size= 2, strides = None, padding = 'valid'))
model_adam_dropout.add(tf.keras.layers.Dropout(0.2))
model_adam_dropout.add(tf.keras.layers.Flatten())
model_adam_dropout.add(tf.keras.layers.Dense(units = 1, activation = 'sigmoid'))
model_adam_dropout.summary()
model_adam_dropout.compile(loss='binary_crossentropy',
              optimizer='adam',
              metrics=['accuracy'])
# On definit un arret premature de l'entrainement en cas d'amelioration non notable apres plusieurs phases pour eviter le sur-entrainement 
early_stop = keras.callbacks.EarlyStopping(monitor = 'val_accuracy', min_delta = 0.0005, patience=8, 
                                           restore_best_weights=True )
# On definit un monitoring les performances du modele et on lance l'entrainement
history_adam_droupout = model_adam_dropout.fit(X_train, y_train, batch_size = 128, validation_data=(X_test, y_test), 
                        epochs=115)
# On plot les résultats de l'entrainement
plt.figure(figsize = (10,4))
plt.subplot(121)
plt.plot(history_adam_droupout.epoch, history_adam_droupout.history["loss"], 'g', label='Training loss')
plt.plot(history_adam_droupout.epoch, history_adam_droupout.history["val_loss"], 'b', label='Validation loss')
plt.title('Training loss')
plt.xlabel('Epochs')
plt.ylabel('Loss')
plt.legend()
plt.subplot(122)
plt.plot(history_adam_droupout.epoch, history_adam_droupout.history["accuracy"], 'r', label='Training accuracy')
plt.plot(history_adam_droupout.epoch, history_adam_droupout.history["val_accuracy"], 'orange', label='Validation accuracy')
plt.title('Training accuracy')
plt.xlabel('Epochs')
plt.ylabel('Accuracy')
plt.legend()
plt.savefig(work_folder + "base_model_deep_dropout_adam.png")
plt.show()

A noter que lorsque le modèle est entraî­né et rend des pré­dic­tion, l'ensemble des neu­rones sont connec­tés. Le dro­pout ne concerne que l'apprentissage.

Résul­tats d'entrainement avec un opti­mi­seur ADAM et avec drou­pout
# Evaluation finale du modele
loss, acc = model_adam_dropout.evaluate(X_validation, y_validation, verbose=2)
print("Untrained model, accuracy: {:5.2f}%".format(100 * acc))
71/71 - 0s - loss: 0.2720 - accuracy: 0.8819 - 365ms/epoch - 5ms/step
Untrained model, accuracy: 88.19%

Nous obser­vons comme dans l'exemple pré­cé­dent, l'atteinte d'un pla­teau au niveau des courbes d'erreur et de jus­tesse, autant pour l'entrainement que la vali­da­tion.

D'autre part, nous n'observons plus de diver­gence notable entre entrai­ne­ment et vali­da­tion (en erreur et en jus­tesse) au long de l'entrainement. Nous sem­blons donc avoir mené à bien l'entrainement du modèle sans pro­vo­quer de sur-appren­tis­sage.

Il est à noter que la der­nière valeur de jus­tesse est plus basse avec l'utilisation du dro­pout (envi­ron 85%) que lors de la pré­cé­dente expé­rience (envi­ron 90%). Pour­tant lors de l'évaluation du modèle, les per­for­mances passent de 85% pour le modèle pré­cé­dent à 88% pour ce modèle.

Les ten­ta­tives de rendre les modèles plus géné­ra­li­sables s'accompagnent par­fois de pertes de per­for­mances. Tou­te­fois nous sem­blons ici avoir éta­bli un modèle aux per­for­mances hono­rables.

Conclusion

Ain­si s'achève une pre­mière expé­rience d'application de deep-lear­ning à des pro­blé­ma­tiques de bio­in­for­ma­tique. Nous avons construit un modèle per­met­tant de clas­ser des séquences de 301 nucléo­tides entre séquences pro­mo­trices et non-pro­mo­trices.

Nous avons pu voir que de nom­breux hyper-para­mètres entraient en ligne de compte lors de la construc­tion et lors de l'entrainement du modèle, afin de le rendre plus robuste aux nou­veaux exemples, et de l’entraîner dans des délais rai­son­nables.

Il est à gar­der à l'esprit que cette archi­tec­ture de réseau n'est pas la seule per­met­tant de répondre à cette ques­tion, ni même la meilleure. De nom­breuses pos­si­bi­li­tés sont offertes afin de par­faire ce modèle, qu'il s'agisse de para­mé­tri­sa­tion ou d'architecture.

L'ensemble du code est dis­po­nible sur ce repo : https://​github​.com/​b​i​o​i​n​f​o​-​f​r​/​d​a​t​a​_​i​a​/​b​l​o​b​/​m​a​i​n​/​D​N​A​_​C​N​N​_​p​r​o​m​o​t​e​r​s​.​i​p​ynb

Pour aller plus loin avec les réseaux convolutifs

Quelques res­sources sur les CNN : https://​toward​sda​tas​cience​.com/​a​-​c​o​m​p​r​e​h​e​n​s​i​v​e​-​g​u​i​d​e​-​t​o​-​c​o​n​v​o​l​u​t​i​o​n​a​l​-​n​e​u​r​a​l​-​n​e​t​w​o​r​k​s​-​t​h​e​-​e​l​i​5​-​w​a​y​-​3​b​d​2​b​1​1​6​4​a53

Un grand mer­ci à nos relec­teurs pour cet article : Léo­pold, Samuel, Yoann et Guillaume !

Vous avez aimé ? Dites-le nous !

Moyenne : 5 /​ 5. Nb de votes : 1

Pas encore de vote pour cet article.

We are sor­ry that this post was not use­ful for you !

Let us improve this post !

Tell us how we can improve this post ?




Commentaires

4 réponses à “Bioinformatique et IA : un premier pas”

  1. Mer­ci pour l'article très didac­tique !

    J'ai une ques­tion sur le one-hot enco­ding (je la pose sou­vent, mais j’oublie tout aus­si vite les réponses qu'on me fait :-S ). Si je com­prend bien, en sta­tis­tique ils appellent ça dum­my-variable (les deux domaines ont un goût pour le jar­gon qui n'est pas loin de riva­li­ser avec celui des admi­nis­tra­tions), et on nous apprend à faire très atten­tion avec la "mul­ti-col­li­nea­ri­té" des dum­my-variable si on n'enlève pas un colonne. En effet, dans ton exemple, la colonne "T" peut se déduire par­fai­te­ment des colonnes A, C et G, il y a donc une cor­ré­la­tion par­faite entre la colonne T et les trois autres colonnes. Ca cause tout un tas de sou­cis que je ne com­prends pas com­plè­te­ment, à base de matrices non inver­sibles, de para­mètres mal esti­més, et d’erreurs stan­dards énormes (https://​en​.wiki​pe​dia​.org/​w​i​k​i​/​M​u​l​t​i​c​o​l​l​i​n​e​a​r​ity).

    Du coup dans tes méthodes keras, est-ce que ça change quelque chose si tu enlèves une colonne après le one-got enco­ding ? Est-ce que ça amé­liore le loss, l'accuracy ? Est-ce que ça amé­liore la conso­ma­tion de res­sources (RAM, C/​GPU) ? Est-ce qu'au contraire c'est catas­tro­phique ? Est-ce que keras repère le sou­cis et vire une colonne tout seul comme un grand ?

    Je pour­rais tes­ter tout ça comme un grand, vu que tu as fait l'éffort de bien tout nous foru­nir, mer­ci beau­coup !

    Un autre solu­tion c'est de gar­der les séquences "N" et de les enco­der comme ligne {0,0,0,0} dans le one-hot enco­ding, mais c'est pas for­cé­ment hyper malin, vu que j'imagine qu'il y aura plus de N dans les séquences non pro­mo­trices sur des don­nées réelles, et on n'a pas for­cé­ment envie que le modèle apprenne ça. Après dans ton exemple, on s'en fiche vu qu'il n'y a qu'une seule séquence avec un N si je com­prend bien.

    1. Mer­ci pour ton retour. En théo­rie l'une des colonnes cor­res­pon­dant à l'un des nucléo­tides peut être déduite des 3 autres colonnes, ce qui limi­te­rai la mémoire uti­li­sée. Une autre pos­si­bi­li­té serait d'encoder les dif­fé­rents nucléo­tides avec des niveaux (0, 1, 2 et 3) plu­tôt que des vec­teurs one-hot. En pra­tique il serait alors néces­saire d'ajuster l'architecture du réseau de manière à ce que les dimen­sions soient cohé­rentes, en l’occurrence pas­ser de séquences repré­sen­tées par des ten­seurs de dimen­sion 301x4 à des ten­seurs de 301x1.

      Pour cette pro­blé­ma­tique le gain serait rela­ti­ve­ment faible. D'autre part si la mémoire était un fac­teur limi­tant, Ten­sor­flow (et Python par exten­sion) peuvent uti­li­ser un sys­tème d'itérateur per­met­tant de géné­rer les don­nées depuis le data-set "on-flight" ce qui évite d'avoir à char­ger l'ensemble de l'information en mémoire.

      Pour ce qui est des séquences "N" on pour­rait effec­ti­ve­ment ima­gine un 5ème niveau [0, 0, 0, 0]. Dans ce cas le pro­blème ne serait plus une clas­si­fi­ca­tion binaire, mais ter­naire ce qui impli­que­rait des modi­fi­ca­tions dans la pré­pa­ra­tion des labels avec un troi­sième classe "nature indé­ter­mi­née" ain­si qu'un chan­ge­ment au niveau de la fonc­tion de perte pas­sant de "bina­ry cross-entro­py" à "cate­go­ri­cal cross-entro­py". De fait, cela serait plus cou­teux d'effectuer un trai­te­ment de telles séquences à pos­te­rio­ri une fois la clas­si­fi­ca­tion faite.

      J'espère avoir répon­du à tes inter­ro­ga­tions 😉

  2. Avatar de Jouneau Luc
    Jouneau Luc

    Bon­jour,
    Mer­ci pour ce post ! J'ai vou­lu le tes­ter (en local sans GPU) mais je ren­contre une dif­fi­cul­té lors de l'entrainement du pre­mier modèle :

    his­to­ry = model.fit(X_train, y_​train, batch_​size = 128, validation_data=(X_test, y_​test),
    epochs=115)

    ValueEr­ror : Input 0 of layer sequen­tial is incom­pa­tible with the layer : expec­ted axis -1 of input shape to have value 4 but recei­ved input with shape (None, 301, 4, 1)

    Il semble pour­tant que j'ai les mêmes dimen­sions que ce que vous mon­trez :

    X_train.shape
    (18078, 301, 4, 1)
    X_test.shape
    (2260, 301, 4, 1)
    y_train.shape
    (18078,)
    y_test.shape
    (2260,)

    Voyez-vous ce qui cloche (j'exécute en Python-3.7.4) ?

    1. Bon­jour,

      Pour­riez-vous me faire par­ve­nir l'ensemble de votre code ? Je n'arrive pas à repro­duire l'erreur.

Laisser un commentaire

Pour insérer du code dans vos commentaires, utilisez les balises <code> et <\code>.