Test Run

Evolutionäre Optimierungsalgorithmen

James McCaffrey

James McCaffrey

Ein evolutionärer Optimierungsalgorithmus ist die Implementierung einer Metaheuristik, die auf der Grundlage biologischer Evolutionsprozesse modelliert ist. Solche Algorithmen können dazu genutzt werden, Näherungslösungen für schwierige oder unlösbare numerische Minimierungsprobleme zu finden. Sie sollten sich aus drei Gründen für evolutionäre Optimierungsalgorithmen interessieren. Erstens kann Fähigkeit, solche Algorithmen zu kodieren, eine praktisch nutzbare Ergänzung Ihrer Kenntnisse als Entwickler, Manager und Tester sein. Zweitens kann eine Reihe der dabei verwendeten Techniken, wie etwa die Tournierauswahl, auch in anderen Algorithmen und Kodierungsszenarien zum Einsatz kommen. Und drittens finden viele Entwickler diese Algorithmen an sich hochinteressant.

Ein evolutionärer Optimierungsalgorithmus ist im Kern ein genetischer Algorithmus, in dem die virtuellen Chromosomen aus realen Werten und nicht aus einer Art von Bit-Repräsentation bestehen. Um einen besseren Eindruck davon zu bekommen, worum es in diesem Artikel genau geht, können Sie sich Abbildung 1 und Abbildung 2 ansehen.

Schwefel’s Function
Abbildung 1 – Schwefels Funktion

Evolutionary Optimization Demo
Abbildung 2 Demonstration eines evolutionären Optimierungsalgorithmus

Die Grafik in Abbildung 1 zeigt Schwefels Funktion, ein numerisches Standardminimierungsproblem für Benchmarkingzwecke. Schwefels Funktion ist wie folgt definiert:

f(x,y) = (-x * sin(sqrt(abs(x)))) + (-y * sin(sqrt(abs(y))))

Die Funktion hat den Mindestwert -837,9658, wenn x = y = 420,9687. In Abbildung 1 befindet sich dieser Mindestwert am linken Rand der Grafik. Die Funktion wurde absichtlich so gestaltet, dass sie von Optimierungsalgorithmen aufgrund der zahlreichen falschen lokalen Mindestwerte schwer zu lösen ist.

Das Bildschirmfoto in Abbildung 2 zeigt die Ausgabe einer C#-Konsolenanwendung. Nach der Anzeige einiger Informationsmeldungen setzt das Programm acht Parameter und verwendet dann einen evolutionären Algorithmus für die Suche nach der optimalen Lösung. In diesem Beispiel fand der Algorithmus als beste Lösung (420,9688, 420,9687), was der optimalen Lösung von (420,9688, 420,9687) sehr nahe kommt, aber damit nicht identisch ist.

Evolutionäre Optimierungsalgorithmen modellieren eine Lösung als Chromosom in einem Individuum. In High-Level-Pseudocode ist der in Abbildung 2 implementierte Algorithmus:

initialize a population of random solutions
determine best solution in population
loop
  select two parents from population
  make two children from the parents
  place children into population
  make and place an immigrant into population
  check if a new best solution exists
end loop
return best solution found

In den nachfolgenden Abschnitten stelle ich den gesamten Code vor, der das Bildschirmfoto in Abbildung 2 generiert hat. Sie finden den Code unter archive.msdn.microsoft.com/mag201206TestRun. Dieser Artikel geht davon aus, dass Sie über mittlere oder fortgeschrittene Programmierkenntnisse und eine mindestens grundlegende Kenntnis genetischer Algorithmen verfügen. Ich habe das Demoprogramm mit C# kodiert, Sie sollten jedoch den Code auch problemlos in anderen Programmiersprachen refaktorieren können, etwa in Visual Basic .NET oder IronPython.

Programmstruktur

Die allgemeine Programmstruktur ist in Abbildung 3 gezeigt. Ich verwendete Visual Studio 2010 zur Erstellung eines neuen C#-Konsolenanwendungsprojekts mit der Bezeichnung EvolutionaryOptimization. Im Solution Explorer-Fenster benannte ich Program.cs in EvolutionaryOptimizationProgram.cs um, wodurch automatisch die Klasse Program umbenannt wurde. Darüber hinaus habe ich alle nicht benötigten von der Vorlage generierten Using-Anweisungen gelöscht.

Abbildung 3 Allgemeine Struktur des EvolutionaryOptimization-Programms

using System;
namespace EvolutionaryOptimization
{
  class EvolutionaryOptimizationProgram
  {
    static void Main(string[] args)
    {
      try
      {
        int popSize = 100;
        int numGenes = 2;
        double minGene = -500.0; double maxGene = 500.0;
        double mutateRate = 1.0 / numGenes;
        double precision = 0.0001;
        double tau = 0.40;
        int maxGeneration = 8000;
        Evolver ev = new Evolver(popSize, numGenes, 
          minGene, maxGene, mutateRate,
          precision, tau, maxGeneration);
        double[] best = ev.Evolve();
        Console.WriteLine("\nBest solution found:");
        for (int i = 0; i < best.Length; ++i)
          Console.Write(best[i].ToString("F4") + " ");
        double fitness = Problem.Fitness(best);
        Console.WriteLine("\nFitness of best solution = " 
          + fitness.ToString("F4"));
      }
      catch (Exception ex)
      {
        Console.WriteLine("Fatal: " + ex.Message);
      }
    }
  }
  public class Evolver
  {
    // Member fields
    public Evolver(int popSize, int numGenes, 
      double minGene, double maxGene,
      double mutateRate, double precision, 
      double tau, int maxGeneration) { ... }
    public double[] Evolve() { ... }
    private Individual[] Select(int n) { ... }
    private void ShuffleIndexes() { ... }
    private Individual[] Reproduce(Individual parent1, 
      Individual parent2) { ... }
    private void Accept(Individual child1, 
      Individual child2) { ... }
    private void Immigrate() { ... }
  }
  public class Individual : IComparable<Individual>
  {
    // Member fields
    public Individual(int numGenes, 
      double minGene, double maxGene,
      double mutateRate, double precision) { ... }
    public void Mutate() { ... }
    public int CompareTo(Individual other) { ... }
  }
  public class Problem
  {
    public static double Fitness(double[] chromosome) { ... }
  }
} // NS

Zusätzlich zu der Hauptprogrammklasse verfügt das EvolutionaryOptimization-Demo über drei programmdefinierte Klassen. Die Klasse Evolver enthält den Hauptanteil der Algorithmuslogik. Die Klasse Individual modelliert eine mögliche Lösung für das Minimierungsproblem. Die Klasse Problem definiert die zu minimierende Funktion, in diesem Fall Schwefels Funktion. In einer alternativen Struktur würde die Klasse Individual in die Klasse Evolver gesetzt.

Die Klasse Individual

Die Definition der Klasse Individual beginnt:

public class Individual : IComparable<Individual>
{
  public double[] chromosome;
  public double fitness;
  private int numGenes;
  private double minGene;
  private double maxGene;
  private double mutateRate;
  private double precision;
  static Random rnd = new Random(0);
...

Die Klasse erbt vom IComparable-Interface, so dass Arrays von Individual-Objekten automatisch nach Fitness (Eignung) sortiert werden können. Die Klasse hat acht Datenmitglieder. Das Chromosomen-Array repräsentiert eine mögliche Lösung des Zielproblems. Beachten Sie, dass das Chromosom ein "Double"-Array ist und kein Array mit einer Art Bit-Repräsentation, wie sie typischerweise von genetischen Algorithmen verwendet werden. Evolutionäre Optimierungsalgorithmen werden auch bisweilen als echtwert-genetische Algorithmen bezeichnet.

Das "Fitness"-Feld ist ein Maß dafür, wie gut die Chromosomenlösung ist. Für Minimierungsprobleme sind kleinere Fitnesswerte besser geeignet als größere. Aus Einfachheitsgründen sind Chromosom und Fitness mit öffentlichem Scope deklariert, damit sie für die Logik in der Evolver-Klasse sichtbar sind. Ein gen ist ein Wert in dem Chromosomenarray, und das Feld numGenes enthält die Anzahl der echten Werte in einer möglichen Lösung. Für Schwefels Funktion wird dieser Wert auf 2 gesetzt. Bei vielen numerischen Optimierungsproblemen können Mindest- und Höchstwerte für jedes Gen angegeben werden, und diese Werte werden in minGene und maxGene gespeichert. Wenn diese Werte nicht bekannt sind, können minGene und maxGene auf die doppelten Werte von MinValue und MaxValue gesetzt werden. Ich werde die Felder mutateRate und Präzision erläutern, wenn ich den Code vorstelle, der sie verwendet.

Die Definition der Klasse Individual fährt mit dem Klassenconstructor fort:

public Individual(int numGenes, double minGene, double maxGene,
  double mutateRate, double precision)
{
  this.numGenes = numGenes;
  this.minGene = minGene;
  this.maxGene = maxGene;
  this.mutateRate = mutateRate;
  this.precision = precision;
  this.chromosome = new double[numGenes];
  for (int i = 0; i < this.chromosome.Length; ++i)
    this.chromosome[i] = (maxGene - minGene) 
    * rnd.NextDouble() + minGene;
  this.fitness = Problem.Fitness(chromosome);
}

Der Constructor weist dem Chromosomen-Array Speicher zu und ordnet jeder Genzelle zufällige Werte aus dem Bereich (minGene, maxGene) zu. Beachten Sie, dass der Wert des Felds "Fitness" durch Aufruf der extern definierten Fitness-Methode gesetzt wird. Als Alternative könnten Sie auch per Delegate eine Referenz zur Fitness-Methode in den Constructor eingeben. Die Methode "Mutate" ist folgendermaßen definiert:

public void Mutate()
{
  double hi = precision * maxGene;
  double lo = -hi;
  for (int i = 0; i < chromosome.Length; ++i) {
    if (rnd.NextDouble() < mutateRate)
      chromosome[i] += (hi - lo) * rnd.NextDouble() + lo;
  }
}

Die Mutationsoperation durchläuft das Chromosomen-Array und ändert zufällig ausgewählte Gene zu Zufallswerten in dem Bereich (lo, hi). Der Bereich der zuzuweisenden Werte wird durch die Werte "Präzision" und "maxGene" bestimmt. In dem Beispiel in Abbildung 2 ist die Präzision auf 0,0001 und maxGene auf 500 gesetzt. Der höchste mögliche Wert für eine Genmutation ist 0,0001 * 500 = 0,05; dies bedeutet, dass dann, wenn ein Gen mutiert wird, sein neuer Wert zum alten Wert plus oder minus einem Zufallswert zwischen -0,05 und +0,05 wird. Beachten Sie, dass der Präzisionswert der Anzahl der Dezimalstellen in der Lösung entspricht; dies ist eine angemessene Heuristik für den Präzisionswert. Der Wert der Mutationsrate steuert, wie viele Gene in dem Chromosom geändert werden. Eine Heuristik für den Wert des mutateRate-Felds ist die Verwendung von 1,0/numGenes, so dass durchschnittlich ein Gen in dem Chromosom bei jedem Aufruf von Mutate mutiert wird.

Die Definition der Klasse Individual schließt mit der CompareTo-Methode:

...
  public int CompareTo(Individual other)
  {
    if (this.fitness < other.fitness) return -1;
    else if (this.fitness > other.fitness) return 1;
    else return 0;
  }
}

Die CompareTo-Methode definiert eine Standardsortierreihenfolge für Individual-Objekte, in diesem Fall von der geringsten (am besten) zur höchsten Fitness.

Die Problem-Klasse

Die Problem-Klasse enthält das Zielproblem für den evolutionären Algorithmus:

public class Problem
{
  public static double Fitness(double[] chromosome)
  {
    double result = 0.0;
    for (int i = 0; i < chromosome.Length; ++i)
      result += (-1.0 * chromosome[i]) *
        Math.Sin(Math.Sqrt(Math.Abs(chromosome[i])));
    return result;
  }
}

Da ein Chromosomen-Array eine mögliche Lösung repräsentiert, wird es als Eingabeparameter an die Fitness-Methode übergeben. Bei arbiträren Minimierungsproblemen wird die zu minimierende Zielfunktion oft als Kostenfunktion bezeichnet. Im Kontext evolutionärer und genetischer Algorithmen sprechen wir jedoch gewöhnlich von einer Fitness-Funktion. Diese Terminologie ist nicht sehr elegant, da niedrigere Fitnesswerte besser sind als hohe. In diesem Beispiel ist die Fitness-Funktion vollständig selbstständig. In vielen Optimierungsproblemen erfordert die Fitness-Funktion zusätzliche Eingabeparameter, wie etwa eine Datenmatrix oder eine Referenz einer externen Datendatei.

Die Evolver-Klasse

Die Definition der Klasse Evolver beginnt:

public class Evolver
{
  private int popSize;
  private Individual[] population;
  private int numGenes;
  private double minGene;
  private double maxGene;
  private double mutateRate;
  private double precision;
  private double tau;
  private int[] indexes;
  private int maxGeneration;
  private static Random rnd = null;
...

Das popSize-Mitglied enthält die Anzahl der Individuen in der Population. Höhere popSize-Werte erhöhen gewöhnlich die Präzision des Algorithmus, jedoch auf Kosten der Geschwindigkeit. Allgemein sind evolutionäre Algorithmen viel schneller als gewöhnliche genetische Algorithmen, da sie mit echten Werten operieren und nicht Bit-Repräsentationen konvertieren und manipulieren müssen. Das Herzstück der Evolver-Klasse ist ein Array von Individual-Objekten mit der Bezeichnung "Population". Das Tau und die Index-Mitglieder werden von der Selection-Methode verwendet, wie Sie gleich sehen werden.

Die Definition der Evolver-Klasse fährt mit der in Abbildung 4 gezeigten Constructordefinition fort.

Abbildung 4 Constructor der Evolver-Klasse

public Evolver(int popSize, int numGenes, double minGene, double maxGene,
  double mutateRate, double precision, double tau, int maxGeneration)
{
  this.popSize = popSize;
  this.population = new Individual[popSize];
  for (int i = 0; i < population.Length; ++i)
    population[i] = new Individual(numGenes, minGene, maxGene,
      mutateRate, precision);
  this.numGenes = numGenes;
  this.minGene = minGene;
  this.maxGene = maxGene;
  this.mutateRate = mutateRate;
  this.precision = precision;
  this.tau = tau;
  this.indexes = new int[popSize];
  for (int i = 0; i < indexes.Length; ++i)
    this.indexes[i] = i;
  this.maxGeneration = maxGeneration;
  rnd = new Random(0);
}

Der Constructor weist dem Population-Array Speicher zu und verwendet dann den Individual-Constructor, um das Array mit Individuen zu füllen, die zufällige Genwerte haben. Das Array mit der Bezeichnung Indexes wird von der Select-Methode verwendet, die zwei übergeordnete Elemente ("Parents") auswählt. Ich werde Indexes später erläutern, beachten Sie jedoch, dass der Constructor eine Zelle pro Individuum zuweist und sequenziell Ganzzahlen von 0 bis popSize-1 zuweist.

Die Evolve-Methode, aufgeführt in Abbildung 5, ist überraschend kurz.

Abbildung 5 Die Evolve-Methode

public double[] Evolve()
{
  double bestFitness = this.population[0].fitness;
  double[] bestChomosome = new double[numGenes];
  population[0].chromosome.CopyTo(bestChomosome, 0);
  int gen = 0;
  while (gen < maxGeneration)  {
    Individual[] parents = Select(2);
    Individual[] children = Reproduce(parents[0], parents[1]);
    Accept(children[0], children[1]);
    Immigrate();
    for (int i = popSize - 3; i < popSize; ++i)  {
      if (population[i].fitness < bestFitness)  {
        bestFitness = population[i].fitness;
        population[i].chromosome.CopyTo(bestChomosome, 0);
      }
    }
    ++gen;
  }
  return bestChomosome;
}

Die Evolve-Methode gibt die beste Lösung aus, die ein Array des Typs "Double" ist. Als Alternative könnten Sie ein Individual-Objekt zurückgeben, bei dem das Chromosom die beste gefundene Lösung enthält. Die Evolve-Methode beginnt mit der Initialisierung der besten Fitness und der besten Chromosomen für die ersten in der Population. Sie wird so oft iteriert, wie der maxGenerations-Wert angibt, wobei "Gen" (Generation) als Schleifenzähler verwendet wird. Eine der möglichen Alternativen besteht darin, anzuhalten, wenn nach einer bestimmten Zahl von Iterationen keine Verbesserung mehr stattgefunden hat. Die Select-Methode gibt zwei gute, jedoch nicht notwendigerweise die besten, Individuen aus der Population zurück. Diese beiden Parents werden an Reproduce weitergegeben, wo zwei "Children" erstellt und zurückgegeben werden. Die Accept-Methode setzt die beiden Children in die Population; diese ersetzen die beiden vorhandenen Individuen. Die Immigrate-Methode generiert ein neues Zufallsindividuum und setzt es in die Population. Die neue Population wird dann geprüft, um zu sehen, ob eines der drei neuen Individuen in der Population die beste Lösung ist.

Die Select-Methode ist folgendermaßen definiert:

private Individual[] Select(int n)
{
  int tournSize = (int)(tau * popSize);
  if (tournSize < n) tournSize = n;
  Individual[] candidates = new Individual[tournSize];
  ShuffleIndexes();
  for (int i = 0; i < tournSize; ++i)
    candidates[i] = population[indexes[i]];
  Array.Sort(candidates);
  Individual[] results = new Individual[n];
  for (int i = 0; i < n; ++i)
    results[i] = candidates[i];
  return results;
}

Die Methode nimmt die Anzahl der auszuwählenden guten Individuen und gibt sie in einem Array des Typs Individual zurück. Um den Code zu minimieren habe ich die normale Fehlerprüfung ausgelassen, wie etwa die Prüfung, ob die Zahl der angefragten Individuen kleiner ist als die Größe der Population. Die Select-Methode verwendet eine Technik, die als "Turnierauswahl (Tournament Selection)" bezeichnet wird. Eine Teilmenge zufälliger Kandidatenindividuen wird generiert, und die besten n davon werden zurückgegeben. Die Zahl der Kandidaten wird in die Variable tournSize berechnet, die einen Bruchteil, Tau, der Populationsgröße darstellt. Größere Werte von Tau erhöhen die Wahrscheinlichkeit, dass die besten zwei Individuen ausgewählt werden.

Denken Sie daran, dass die Evolver-Klasse ein Mitgliedsarray mit der Bezeichnung "Indexes" hat, mit den Werten 0..popSize-1. Die Helfermethode ShuffleIndexes ordnet die Werte in Arrayindizes in zufälliger Reihenfolge neu an. Die obersten n dieser Zufallsindizes werden für die Auswahl von Kandidaten aus der Population verwendet. Die Array.Sort-Methode sortiert dann die Kandidatenindividuen von der kleinsten (besten) Fitness zur größten. Die obersten n Individuen der sortierten Kandidaten werden zurückgegeben. Es gibt viele verschiedene evolutionäre Auswahlalgorithmen. Ein Vorteil der Tournierauswahl gegenüber den meisten anderen Techniken besteht darin, dass der Auswahldruck durch Modifizierung des Tau-Parameters eingestellt werden kann.

Die Helfermethode ShuffleIndexes verwendet den Standard-Fisher-Yates-Mischalgorithmus:

private void ShuffleIndexes()
{
  for (int i = 0; i < this.indexes.Length; ++i) {
    int r = rnd.Next(i, indexes.Length);
    int tmp = indexes[r];
    indexes[r] = indexes[i];
    indexes[i] = tmp;
  }
}

Die Reproduce-Methode ist in Abbildung 6 aufgeführt. Die Methode beginnt mit der Generierung eines zufälligen Crossoverpunkts. Die Indizierung ist etwas kompliziert, aber Child1 wird aus dem linken Teil von Parent1 und dem rechten Teil von Parent2 generiert. Child2 wird aus dem linken Teil von Parent2 und dem rechten Teil von Parent1 generiert. Der Gedanke ist in Abbildung 7 illustriert, wo zwei Parents mit fünf Genen vorhanden sind und der Crossoverpunkt 2 ist. Eine häufig verwendete Alternative ist die Verwendung von mehreren Crossoverpunkten. Nach der Erstellung der einzelnen Child-Objekte werden sie mutiert, und ihre neue Fitness wird berechnet.

Abbildung 6 Die Reproduce-Methode

private Individual[] Reproduce(Individual parent1, Individual parent2)
{
  int cross = rnd.Next(0, numGenes - 1);
  Individual child1 = new Individual(numGenes, minGene, maxGene,
    mutateRate, precision);
  Individual child2 = new Individual(numGenes, minGene, maxGene,
     mutateRate, precision);
  for (int i = 0; i <= cross; ++i)
    child1.chromosome[i] = parent1.chromosome[i];
  for (int i = cross + 1; i < numGenes; ++i)
    child2.chromosome[i] = parent1.chromosome[i];
  for (int i = 0; i <= cross; ++i)
    child2.chromosome[i] = parent2.chromosome[i];
  for (int i = cross + 1; i < numGenes; ++i)
    child1.chromosome[i] = parent2.chromosome[i];
  child1.Mutate(); child2.Mutate();
  child1.fitness = Problem.Fitness(child1.chromosome);
  child2.fitness = Problem.Fitness(child2.chromosome);
  Individual[] result = new Individual[2];
  result[0] = child1; result[1] = child2;
  return result;
}

The Crossover Mechanism
Abbildung 7 Der Crossover-Mechanismus

Die Accept-Methode setzt die beiden von Reproduce erstellten Child-Individuen in die Population.

private void Accept(Individual child1, Individual child2)
{
  Array.Sort(this.population);
  population[popSize - 1] = child1;
  population[popSize - 2] = child2;
}

Das Population-Array wird nach Fitness sortiert, wobei die zwei schlechtesten Individuen in die beiden letzten Zellen des Arrays gesetzt werden, wo sie dann von den Child-Objekten ersetzt werden. Es gibt viele alternative Konzepte zur Auswahl, welche beiden Individuen "sterben". Eine Möglichkeit ist die Verwendung einer als "Roulette Wheel Selection" bezeichneten Methode, bei der die beiden zu ersetzenden Individuen probabilistisch ausgewählt werden. Dabei besteht für schlechtere Individuen eine höhere Wahrscheinlichkeit zum Austausch.

Die Immigrate-Methode generiert ein neues Zufallsindividuum und setzt es in die Population, genau über den Ort der beiden Child-Objekte, die soeben generiert wurden (Immigration hilft zu verhindern, dass evolutionäre Algorithmen bei lokalen Minimallösungen stehen bleiben, Alternativen sind etwa die Erstellung von mehr als einem "Immigranten" und seine Einfügung an einem zufälligen Ort in der Population).

private void Immigrate()
{
  Individual immigrant =
    new Individual(numGenes, minGene, maxGene, mutateRate, precision);
  population[popSize - 3] = immigrant;
}

Ausgangspunkt für Experimente

In diesem Artikel wurde die evolutionäre Optimierung verwendet, um den Mindestwert einer mathematischen Gleichung zu finden. Obwohl evolutionäre Algorithmen in dieser Weise verwendet werden können, werden sie häufiger dazu benutzt, die Werte für einen Satz numerischer Parameter in einem größeren Optimierungsproblem zu finden, für das kein effektiver deterministischer Algorithmus besteht. Zum Beispiel: Wenn Sie ein neuronales Netzwerk für die Klassifizierung vorhandener Daten verwenden, um Prognosen für zukünftige Daten zu erstellen, ist die Hauptschwierigkeit, einen Satz von Neuron-Gewichten und -Tendenzen zu bestimmen. Ein evolutionärer Optimierungsalgorithmus ist eine Methode zur Schätzung der optimalen Gewicht- und Tendenzwerte (Weight und Bias). In den meisten Fällen sind evolutionäre Optimierungsalgorithmen nicht gut für nicht-numerische kombinatorische Optimierungsprobleme geeignet, wie etwa für das "Handlungsreisendenproblem", bei dem es darum geht, die Kombination von Städten mit der kürzesten Gesamtweglänge zu finden.

Evolutionäre Algorithmen sind, wie reine genetische Algorithmen, Meta-Heuristiken. Dies bedeutet, dass sie ein allgemeines Framework mit einem Satz konzeptueller Richtlinien darstellen, das für die Erstellung eines bestimmten Algorithmus für die Lösung eines bestimmten Problems verwendet werden kann. Das in diesem Artikel vorgestellte Beispiel sollte daher mehr als Ausgangspunkt für Experimente und die Erstellung eigener evolutionärer Optimierungscodes gesehen werden als als feste, statische Codebasis.

Dr. James McCaffrey ist für Volt Information Sciences Inc. tätig. Er leitet technische Schulungen für Softwareentwickler, die auf dem Campus von Microsoft in Redmond, USA arbeiten. Er hat an verschiedenen Microsoft-Produkten mitgearbeitet, unter anderem an Internet Explorer und MSN Search. Dr. McCaffrey ist der Autor von ".NET Test Automation Recipes" (Rezepte für die .NET-Testautomatisierung, Apress 2006) und kann unter jammc@microsoft.com erreicht werden.

Unser Dank gilt dem folgenden technischen Experten von Microsoft für die Durchsicht dieses Artikels: Anne Loomis Thompson