Wie GANs helfen, Produktionsfehler zu erkennen


Lorenzo Melchior


Modern automatized car assembly line.

Im Machine Learning behindert oft eine unzureichende Menge an Trainingsdaten die Leistung von Klassifikationsalgorithmen. Die Erfahrung zeigt, dass der Mangel an Trainingsdaten eher die Regel als die Ausnahme ist, weshalb die Menschen clevere Methoden zur Datenvermehrung (Data Augmentation) entwickelt haben.

In diesem Blogbeitrag zeige ich, wie Sie mit einem Generative Adversarial Network (GAN) neue Bilder einer Verteilung von Bildern erstellen können. Dies kann als Data Augmentation-Methode bei Problemen wie der Fehlererkennung in der industriellen Produktion eingesetzt werden.


GANs zur Datenvermehrung


Wir können Datenerweiterung nutzen, wie z.B. leichtes Drehen oder Spiegeln der Originaldaten, um neue Trainingsdaten zu generieren. Aber das bringt uns natürlich nicht wirklich neue Bilder.

GANs wiederum geben in der Tat völlig neue Bilder aus. Vielleicht haben Sie schon von GANs als Mittel zur Erstellung überraschend realistischer "Fake"-Bilder und -Videos (was unter dem Begriff "Deepfake" bekannt geworden ist) gehört. Wie aktuelle Forschungsergebnisse (z.B. Antoniou et al. 2017, Wang et al. 2018 und Frid-Adar et al. 2018) zeigen, können sie auch die Leistung von Klassifikatoren für das maschinelle Lernen verbessern, indem sie zusätzliche Trainingsdaten generieren.

Industrielle Anwendungen

Der GAN-Datenvermehrungsansatz ist besonders vielversprechend, wenn nur sehr wenige Trainingsdaten vorhanden sind.

Stellen Sie sich vor, wir wollen ein ML-Modell trainieren, das in einem Zwischenschritt eines industriellen Produktionsablaufes defekte Komponenten identifiziert. Hoffentlich treten Fehler selten auf; aber das bedeutet auch, dass wir wahrscheinlich nur eine kleine Anzahl von Bildern mit exemplarischen Fehlern haben, um das Netzwerk zu trainieren.

Mit Hilfe eines GANs können wir zusätzliche Bilder für jeden Fehlertyp generieren.

Die Daten

Wir verwenden die NEU-Oberflächenfehlerdatenbank, die 300 Bilder von Kratzern auf Metall enthält, die während der Produktion aufgetreten sind.

 The dataset for data augmentation: images of scratches of metal

Eine GAN ist eine unbeaufsichtigte Lernmethode, für die wir keine Label benötigen. Wir haben keine verschiedenen Arten von gelabelten Bildern, die wir unterscheiden wollen, sondern einen Satz ungelabelter Daten, die wir nachzuahmen versuchen.


Das Netzwerk


Ein GAN lässt sich nicht als ein einziges neuronales Netzwerk auffassen. Vielmehr kombiniert es zwei neuronale Netzwerke, die ein Spiel miteinander spielen. Ich werde kurz die Spielregeln erklären.

Wie GANs funktionieren

Erstens gibt es ein Diskriminatornetzwerk, das nur ein einfaches Convolutional Neural Network (CNN) ist. Zweitens haben wir das Generatornetzwerk, das mehr oder weniger ein umgekehrtes CNN ist. Es erhält eine zufällige Eingabe und erzeugt ein Bild als Ausgabe aus dem Up-Sampling der Eingabe mit transponierten Konvolutionen.

Das Spiel findet wie folgt statt: Der Generator erhält eine zufällige Eingabe und erzeugt ein Bild. Der Diskriminator nimmt abwechselnd erzeugte Bilder und Originalbilder auf (ohne zu wissen, welches welches ist) und versucht vorherzusagen, ob es sich bei einem bestimmten Bild um ein Original oder ein erzeugtes Bild handelt, wobei nur die Merkmale des Bildes berücksichtigt werden.

Beide Netzwerke versuchen, mit der Zeit besser zu werden. Der Diskriminator versucht, die realen von den erzeugten Bildern zu unterscheiden, während der Generator darauf abzielt, den Diskriminator dazu zu bringen, seine Bilder für echt zu halten.

Formal betrachtet spielt das Netzwerk das folgende Min-Max-Spiel:

$$\underset{G}{\min}\ \underset{D}{\max}\ V(D,G),$$

Hier ist z ein Zufallsrauschen, das den Generator G aktiviert, um ein Bild G(z) zu erzeugen. D ist der Diskriminator, der vorhersagt, ob ein Bild echt oder erzeugt ist, d.h. D(x) ist die Wahrscheinlichkeit, dass x ein reales Bild ist. pdata ist die Verteilung der Originaldaten, pz die Verteilung des Rauschens.

Der Diskriminator versucht also, seinen Erfolg zu maximieren, während der Generator versucht, ihn zu minimieren.

Die Konfiguration 

Wir verwenden Python mit PyTorch, etwas NumPy, Pandas und Matplotlib für Visualisierungen. Für das Modell haben wir uns für diese Konfigurationen entschieden:

batch_size = 12
generator_depth = 64
discriminator_depth = 128 loss_function=nn.BCELoss()
number_of_epochs = 128
discriminator_optimizer = optim.Adam(discriminator.parameters(), lr=0.0004, betas(0.5,0.999))
generator_optimizer = optim.Adam(generator.parameters(), lr=0.0001, betas=(0.5,0.999))

Im folgenden Codeblock definieren wir den Diskriminator, der das Bild als Eingabe erhält. Wir definieren eine Reihe von Filtern, die das Modell verwendet, um dieses Eingangsbild zu klassifizieren. Wenn wir es trainieren, passen wir diese Filter so an, dass es lernt, zwischen ursprünglichen und generierten Bildern zu unterscheiden.

class Discriminator(nn.Module):
    '''
    The Discriminator that shall distinguish between dataset images and the ones generated by the generator.
    '''
    def __init__(self, number_of_gpus):
        super(Discriminator, self).__init__()
        self.ngpu = number_of_gpus
        
        self.layer1 = nn.Sequential(
            spectral_norm(nn.Conv2d(in_channels=3, out_channels=discriminator_depth, 
                                    kernel_size=(4,4), stride=2, padding=1, bias=False)),
            nn.LeakyReLU(0.2, inplace=True)
        )
        
        self.layer2 = nn.Sequential(
            spectral_norm(nn.Conv2d(in_channels=discriminator_depth, out_channels=discriminator_depth*2, 
                                    kernel_size=(4,4), stride=2, padding=1, bias=False)),
            nn.BatchNorm2d(discriminator_depth*2),
            nn.LeakyReLU(0.2, inplace=True)
        )
        
        self.layer3 = nn.Sequential(
            spectral_norm(nn.Conv2d(in_channels=discriminator_depth*2, out_channels=discriminator_depth*4, 
                                    kernel_size=(4,4), stride=2, padding=1, bias=False)),
            nn.BatchNorm2d(discriminator_depth*4),
            nn.LeakyReLU(0.2, inplace=True)
        )
        
        self.layer4 = nn.Sequential(
            spectral_norm(nn.Conv2d(in_channels=discriminator_depth*4, out_channels=discriminator_depth*8, 
                                    kernel_size=(4,4), stride=2, padding=1, bias=False)),
            nn.BatchNorm2d(discriminator_depth*8),
            nn.LeakyReLU(0.2, inplace=True)
        )
        
        self.layer5 = nn.Sequential(
            spectral_norm(nn.Conv2d(in_channels=discriminator_depth*8, out_channels=discriminator_depth*16, 
                                    kernel_size=(4,4), stride=2, padding=1, bias=False)),
            nn.BatchNorm2d(discriminator_depth*16),
            nn.LeakyReLU(0.2, inplace=True)
        )
        
        self.output_layer = nn.Sequential(
            nn.Conv2d(in_channels=discriminator_depth*16, out_channels=1, 
                                    kernel_size=(4,4), stride=1, padding=0, bias=False),
            nn.Sigmoid()
        )


    def forward(self, input_image):
    
        layer1 = self.layer1(input_image)
        layer2 = self.layer2(layer1)
        layer3 = self.layer3(layer2)
        layer4 = self.layer4(layer3)
        layer5 = self.layer5(layer4)
        return self.output_layer(layer5)

Der Generator hat ähnliche Filter wie der Diskriminator, nur umgekehrt. Anstatt die Bilder anzusehen, um Muster zu erkennen, gibt er Bilder zurück, die auf einem Muster basieren, das wir ihm beigebracht haben zu zeichnen. Die Eingabe besteht aus Zufallszahlen, die diese Filter aktivieren ein Bild zu zeichnen.

class Generator(nn.Module):
    '''
    The Generator Network. It is mostly a reversed discriminator with a random input noise which outputs an image.
    '''
    def __init__(self, number_of_gpus):
        super(Generator, self).__init__()
        self.ngpu = number_of_gpus
        
        self.layer1 = nn.Sequential(
            nn.ConvTranspose2d(in_channels=100, out_channels=generator_depth*16, 
                               kernel_size=(4,4), stride=1, padding=0, bias=False),
            nn.BatchNorm2d(num_features=generator_depth*16),
            nn.ReLU(inplace=True)
        )

        self.layer2 = nn.Sequential(
            nn.ConvTranspose2d(in_channels=generator_depth*16, out_channels=generator_depth*8, 
                               kernel_size=(4,4), stride=2, padding=1, bias=False),
            nn.BatchNorm2d(num_features=generator_depth*8),
            nn.ReLU(inplace=True)
        )
        
        self.layer3 = nn.Sequential(
            nn.ConvTranspose2d(in_channels=generator_depth*8, out_channels=generator_depth*4, 
                               kernel_size=(4,4), stride=2, padding=1, bias=False),
            nn.BatchNorm2d(num_features=generator_depth*4),
            nn.ReLU(inplace=True)
        )
            
        self.layer4 = nn.Sequential(
            nn.ConvTranspose2d(in_channels=generator_depth*4, out_channels=generator_depth*2, 
                               kernel_size=(4,4), stride=2, padding=1, bias=False),
            nn.BatchNorm2d(num_features=generator_depth*2),
            nn.ReLU(inplace=True)
        )
        
        self.layer5 = nn.Sequential(
            nn.ConvTranspose2d(in_channels=generator_depth*2, out_channels=generator_depth, 
                               kernel_size=(4,4), stride=2, padding=1, bias=False),
            nn.BatchNorm2d(num_features=generator_depth),
            nn.ReLU(inplace=True)
        )
        
        self.output_layer = nn.Sequential(
            nn.ConvTranspose2d(in_channels=generator_depth, out_channels=3, 
                               kernel_size=(4,4), stride=2, padding=1, bias=False),
            nn.Tanh()
        )
        

    def forward(self, input_noise):
        
        layer1 = self.layer1(input_noise)
        layer2 = self.layer2(layer1)
        layer3 = self.layer3(layer2)
        layer4 = self.layer4(layer3)
        layer5 = self.layer5(layer4)
        return self.output_layer(layer5)

Training

Wir können das Training in drei Teile unterteilen. 

Training des Diskriminators mit realen Bildern:

discriminator.zero_grad()

prediction = discriminator(batch)

labels_for_dataset_images = torch.ones((batch_size,), device=device).view(-1)

loss_discriminator = loss_function(prediction.view(-1), labels_for_dataset_images)
loss_discriminator.backward()

Training des Diskriminators mit den vom Generator erzeugten Bildern:

random_noise = torch.randn(batch_size,100,1,1, device=device)        
generated_image = generator(random_noise)

labels_for_generated_images = torch.zeros(np.prod(prediction.size()), device=device)

prediction = discriminator(generated_image.detach())

loss_generator = loss_function(prediction.view(-1), labels_for_generated_images)
loss_generator.backward()

discriminator_optimizer.step()

Training des Generators:

generator.zero_grad()

prediction = discriminator(generated_image).view(-1)

loss_generator = loss_function(prediction, labels_for_dataset_images)
loss_generator.backward()

generator_optimizer.step()

Ergebnisse


 Images of scratches on metal

Regenerieren wir nur die Bilder aus dem Datensatz?

Wenn der Generator overfittet, könnten wir Bilder erhalten, die sehr ähnlich oder sogar fast identisch mit Bildern aus dem Datensatz sind. Das ist natürlich nicht das Ergebnis, das wir wollen. Deshalb testen wir, wie ähnlich unsere generierten Bilder den Bildern im Datensatz sind.

Ich benutze den k-nearest-neighbour approach. Dies ist ein Klassifizierungsalgorithmus, der nach den "nächstgelegenen" Bildern aus dem zu klassifizierenden Bild zu allen Bildern im Datensatz sucht.

In unserem Fall bedeutet die Berechnung des Abstands, jedes Pixel als Dimension zu betrachten und dann den euklidischen Abstand zwischen diesen (128x128)-dimensionalen Bildern zu berechnen.

Werfen wir einen Blick auf die nächstgelegenen oder ähnlichsten Bilder aus dem Originaldatensatz für einige unserer erzeugten Bilder:

def euclidean_distance(a, b):
    '''
    Calculates the euklidean Distance of two torch tensors of the same size.
    '''
    return torch.sqrt(((a - b) ** 2).sum())


def get_k_nearest_samples(image, k):
    '''
    Searches for the k-nearest samples in the dataset of a given image based on the euclidean distance.
    '''
    return np.argsort([euclidean_distance(image[0][0], sample[0][0]) for sample in dataset])[:k]
 Result graphic with comparison between generated and most similar images from dataset

Die Bilder sind ähnlich wie die Datensatzbilder, aber sie sind nicht allzu ähnlich - also hat der Generator nicht overfittet.  


Fazit


Das Generative Adversarial Network hat in der Tat gelernt, wie man aus der gegebenen Datenverteilung neue Bilder generiert: Sie sind wirklich neu, weil sie nicht nur Kopien der Originalbilder sind, und lassen sich dennoch nicht von den Originalbildern unterscheiden. Also könnten wir diese neu erstellten Bilder nutzen, um ein Fehlererkennungs- oder Fehlerklassifikationsmodell zu trainieren.

Natürlich sollte man in der Praxis immer noch einmal überprüfen, ob sich die von GAN erzeugten Bilder wirklich positiv auf die Modellleistung auswirken. Dies ist möglicherweise nicht immer der Fall.

Allerdings gibt es viele potenzielle Anwendungsfälle für GANs (nicht nur) in der industriellen Produktion. Aufgrund des aktuellen Forschungsinteresses an GANs werden wir bald viele neue Erkenntnisse darüber haben, wann und wie wir sie sich optimal nutzen lassen. 

Schließlich eine kleine Warnung: GANs sind ziemlich empfindlich und kleine Änderungen der Parameter können zu verzerrten Ergebnissen führen. Außerdem ist das Training eine rechenintensive Aufgabe, da man zwei Netzwerke auf einmal trainieren muss. Ohne eine leistungsfähige GPU kommt man nicht weit.

Wir haben bereits ein Projekt zur Fehlererkennung von Halbleitern erfolgreich umgesetzt. Dieses können Sie sich hier ansehen: Automatische Defekterkennung in der Produktion.