Skin Effekt Belag and Impedanz


Vor kurzem habe ich für pymininec, meine Re-Implementierung des Mininec Antennen-Modellierungs-Codes Skin Effekt Belag (Drähte mit einem endlichen Widerstand) implementiert. Das Original-Mininec hatte dieses Feature nicht.

Um die Implementierung zu verifizieren entschied ich einer uralten Publikation von R. P. Haviland, W4MB [1] zu folgen wo er unter anderem den Effekt von Draht-Widerstand auf die Speisepunkt-Impedanz von Antennen untersucht. Er hat zwei Grafiken wo der Real- und Imaginärteil der Speisepunkt-Impedanz eines Dipols gegen das Verhältnis von Länge zu Durchmesser des Drahtes geplottet wird. Er erwähnt zwar Mininec im Text aber bei genauerer Analyse ist es nicht ganz klar ob für die Plots der Speisepunkt-Impedanz wirklich Mininec oder etwas anderes verwendet wurde. Ich habe versucht, seine Grafiken mit PyNEC, einem Python wrapper für eine C++ Implementierung von NEC-2, und meiner pymininec Implementierung von Mininec zu reproduzieren. Die Resultate von Haviland haben die gleiche Tendenz wie die folgenden NEC Resultate aber sind nicht gleich.

Die X-Achse dieser Grafiken ist logarithmisch und die Drähte werden nach rechts dicker (die X-Achse ist das Verhältnis von Länge zu Durchmesser des Drahtes). In allen Fällen vergleichen wir Drähte ohne Widerstand mit Kupferdrähten. "Resistance" ist der (ohmsche) Widerstand (Realteil der Impedanz) und "Reactance" ist der Blindwiderstand (Reaktanz), der Imaginärteil der Impedanz.

Der ohmsche Widerstand ist nicht überraschend: Der Realteil der Antennen-Impedanz steigt mit dem Draht-Widerstand (weil der Draht dünner wird).

Der interessante Teil ist der Imaginärteil (die Reaktanz) der Antenne. Das NEC-Resultat besagt dass die Reaktanz mit dem Widerstand des Drahtes steigt (nach links) während meine pymininec Implementierung ein leichtes Sinken der Reaktanz vorraussagt.

Um das genauer zu untersuchen habe ich mich entschlossen den spezifischen Widerstand des Drahtes ohne Modifikation des Durchmessers zu ändern um zu zeigen dass der Effekt unabhängig vom Drahtdurchmesser ist. Der Radius ist im Folgenden 66µm bei einer Wellenlänge von 1m, \(\frac{l}{d}\approx 7576\).

Zusätzlich zu PyNEC und pymininec habe ich auch Resultate für MMANA-GAL und EZNEC in die Grafik aufgenommen. MMANA basiert ursprünglich auf Mininec. Die EZNEC Resultate stimmen ziemlich genau mit PyNEC überein (beides verwendet den gleichen Modellierungscode, einmal in Fortran, einmal in C++) aber MMANA hält die Reaktanz komplett konstant bei \(42.63\Omega\) über alle spezifischen Widerstände des Drahtes. Die interessantesten Werte des spezifischen Widerstands sind ganz links die ersten drei großen Punkte, der erste ist ein idealer Draht (spezifischer Widerstand 0) gefolgt von Kupfer und Alu.

Ich habe derzeit keine Ahnung welche Resultate der physischen Wirklichkeit am nächsten kommen.

Schwebung


Musiker kennen den Effekt beim Stimmen von Instrumenten: Wenn zwei Instrumente nicht ganz den selben Ton spielen klingt das als ob es eine periodische Variation der Lautstärke gibt. Die Periode wird größer je besser die beiden Instrumente gestimmt sind und stoppt wenn beide den selben Ton spielen. Wir nennen diesen Effekt "Schwebung".

Funkamateure kennen den "umgekehrten" Fall dieses Effekts von der Amplitudenmodulation einer Radio-Frequenz mit einer Sinusschwingung im Audio-Bereich: Das Resultat sind zwei Radio Frequenzen, eine um die Audio-Frequenz niedriger als die Modulationsfrequenz, eine um die Audio Frequenz höher.

Amplitudenmodulation ist eine einfache Multiplikation: Wir multiplizieren die Radiofrequenz mit dem Audio, wir haben:

$$m(t) = A(t) \cdot\cos(2\pi f t)$$

Wobei $t$ die Zeit ist, $m(t)$ das amplitudenmodulierte Signal, $\cos(2\pi f_r t)$ ist das Radiofrequenz-Signal mit der Frequenz $f_r$ und $A(t)$ ist das Audiosignal. Für eine einfache Audio Sinusschwingung bekommen wir:

$$m(t) = \cos(2\pi f_a t)\cdot\cos(2\pi f_r t)$$

wobei $f_a$ die Audio-Frequenz ist.

Wir möchten jetzt zeigen, dass beide Effekte das gleiche Ding sind. Wir brauchen keine Radiofrequenzen, wir könnenn das gleiche einfach im Audio-Spektrum tun.

Aus Wikipedia bekommen wir eine trigonometrische Identität über Produkte von Winkelfunktionen, daraus nehmen wir die für zwei Cosinus Funktionen:

$$\displaystyle\cos\theta \cos\phi = \frac{\cos(\theta-\phi) + \cos(\theta+\phi)}{2}$$

Wenn wir die auf unser Modulation-Beispiel anwenden bekommen wir:

$$\displaystyle \begin{align} \ \cos(2\pi f_a t) \cos(2\pi f_r t) & = \frac{\cos(2\pi f_r t-2\pi f_a t) + \cos(2\pi f_r t+2\pi f_a t)}{2} \\ & = \frac{\cos(2\pi(f_r-f_a)t)) + \cos(2\pi(f_r+f_a)t)}{2} \\ \end{align} $$

Die Multiplikation von zwei Cosinus-Schwingungen mit unterschiedlichen Frequenzen resultiert in der Summe von zwei Cosinus-Schwingungen, eine davon die Differenz der beiden ursprünglichen Frequenzen, die andere die Summe.

Egal wie wir das modulierte Signal erzeugen, durch Multiplikation von zwei Signalen oder durch Addition von zwei Signalen, wir bekommen das selbe Resultat.

Im folgenden Beispiel multiplizieren wir ein 3Hz Signal (die Schwebungsfrequenz) mit einem 440Hz Signal (Das ist die Note "A" in der Musik). Wir bekommen wie erwartet ein moduliertes Signal das die Lautstärke mit der Schwebungsfrequenz ändert.

In [1]:
import numpy as np
# Drei Sekunden with 18000 Abtastungen
t = np.linspace (0, 3, 18000)
# Produkt von einer 3Hz Schwingung und einem 440Hz Ton
y = np.cos (2*np.pi*3*t) * np.cos (2*np.pi*440*t)
# Wir plotten nur 1/9 der drei Sekunden = 1/3s = eine Periode des 3Hz Signals
limit = int (2000)
In [2]:
import plotly.graph_objs as go
import plotly.io as pio
from plotly_fix_fn import setup_plotly
setup_plotly ('content/2024/05/plotly', '11-de')
show_opt = dict (include_plotlyjs = 'directory')

fig = go.Figure ()
fig.add_trace (go.Scatter (x = t [:limit], y = y [:limit]))
fig.update_layout (title = "Moduliertes Signal", xaxis_title = "Zeit (s)", yaxis_title = "Amplitude")
fig.show (** show_opt)

Nun machen wir eine Fourier-Transformation des Resultats um das Frequenz-Spektrum zu bekommen. Wir sehen dass das Resultat der Multiplikation aus zwei Frequenzen besteht: Ein 3Hz unter 440Hz, eine um 3Hz darüber.

In [3]:
from numpy.fft import rfft, fftfreq
fft   = rfft (y, norm = 'forward')
freqs = fftfreq (len (y), d = 3/18000)
fig = go.Figure ()
fig.add_trace (go.Scatter (x = freqs [:5000], y = np.abs (fft [:5000])))
fig.update_layout (title = "Frequenz-Spektrum", xaxis_title = "Frequenz (Hz)")
fig.update_xaxes (range = [430, 450])
fig.show (** show_opt)

Wir können das selbe Resultat erzielen wenn wir einfach die beiden Frequenzen addieren (und das Ganze durch zwei teilen damit wir die selbe Amplitute haben)

In [4]:
y = 0.5 * (np.cos (2*np.pi*(440-3)*t) + np.cos (2*np.pi*(440+3)*t))
fig = go.Figure ()
fig.add_trace (go.Scatter (x = t [:limit], y = y [:limit]))
fig.update_layout (title = "Moduliertes Signal", xaxis_title = "Zeit (s)", yaxis_title = "Amplitude")
fig.show (** show_opt)

Schließlich verifizieren wir dass dieses Vorgehen das selbe Audiospektrum erzeugt wie vorher die Multiplikation, wir machen eine weitere Fourier-Transformation des neuen Signals:

In [5]:
fft   = rfft (y, norm='forward')
freqs = fftfreq (len (y), d = 3/18000)
fig = go.Figure ()
fig.add_trace (go.Scatter (x = freqs [:5000], y = np.abs (fft [:5000])))
fig.update_layout (title = "Frequenz-Spektrum", xaxis_title = "Frequenz (Hz)")
fig.update_xaxes (range = [430, 450])
fig.show (** show_opt)

Was wir also beim Stimmen von Instrumenten hören ist keine Illusion: Die Addition von zwei Schwingungen mit unterschiedlichen Frequenzen ist das selbe wie die Modulation eines Tons mit einer Schwebungsfrequenz.

Chipselect Aussetzer beim Schreiben auf SPI NOR des IMX-28


Für einen Kunden pflege ich eine Linux Umgebung (inclusive Kernel) für ein kundenspezifischen Board das den IMX-28 ARM Prozessor verwendet. Für den User Space verwende ich ELBE, das Embedded Linux Build Environment das es erlaubt, das User Space Dateisystem aus eine Debian Linux Release zu bauen. Bei der Migration von einer früheren Debian Version zum derzeitigen stabilen Release mit dem Code-Namen "Bookworm" brauchte es ein Kernel Upgrade weil Systemd einige Dinge benötigt die nicht in früheren Kernels enthalten sind.

Nach dem Upgrade von Kernel 4.14 auf 6.1 (beide eine longterm Support Version) funktionierte unser SPI NOR Flash nicht mehr. Das JFFS2 Dateisystem produzierte Fehlermeldungen die nahelegten dass wir ein Problem beim Schreiben in das NOR Flash hatten (Einige der Fehlermeldungen zeigten dass eine 0 erwartet aber 0xFF gelesen wurde was zum Schluss führte dass diese Bytes nicht geschrieben wurden).

Nach Überprüfung der Taktrate des SPI Busses beim Schreiben des Flash mit der Beobachtung dass sich diese nicht geändert hatte schaute ich mir die Signale des SPI Busses an.

Bild Aussetzer im Chip Select

Aussetzer im Chip Select bei der Übertragung

Bild Aussetzer im Chip Select höhere zeitliche Auflösung

Aussetzer im Chip Select bei der Übertragung: Höhere zeitliche Auflösung

In den Oszilloscop Bildern (Blau ist Chip Select, Gelb ist der SPI Takt) sieht man dass es einen kurzen Aussetzer im Chip Select gibt. Das hat den Effekt dass das "Page Program" Kommando an das NOR Flash frühzeitig beendet wird. Aus dem Bild mit der höheren zeitlichen Auflösung sieht man dass dieser Aussetzer mitten in einem Bit der Übertragung stattfindet. Die exakte Position variiert leicht von Übertragung zu Übertragung (Der Aussetzer trifft nicht jedesmal das selbe Bit aber er trifft das selbe Byte).

Weiter Experimente zeigten dass das Problem nur bei Übertragungen mit direktem Speicherzugriff (DMA) auftritt, nicht wenn kein DMA (sondern programmierter Input-Output, PIO) verwendet wurde. Der SPI Treiber optimiert Übertragungen: Nur längere Übertragungen werden mit DMA durchgeführt, kürzere Übertragungen verwenden PIO weil der DMA Overhead dann den Zeit-Gewinn überwiegt. Bei genauerer Inspektion der DMA Implementierung für den IMX-28 in drivers/dma/mxs-dma.c stellte sich heraus dass es eine Änderung bei den Flags des DMA Transfers gab, die alte Version hat:

if (flags & DMA_CTRL_ACK)
        ccw->bits |= CCW_WAIT4END;

Die neue Version hat:

if (flags & MXS_DMA_CTRL_WAIT4END)
        ccw->bits |= CCW_WAIT4END;

Das Bit CCW_WAIT4END (Übersetzt aus der IMX-28 Prozessor Referenz): "Der Wert eins gibt an dass der Kanal auf das Ende des Kommandosignalsvom APBH Device zum DMA wartet bevor ein neues DMA Kommando gestartet wird". Dieses Flag wird für den DMA Transfer zum NOR Flash gesetzt. Eine Besonderheit des IMX-28 ist dass auch Register Einstellungen über DMA übertragen werden können. Vor dem eigentlichen Speichertransfer finden ein Register Transfer statt der das IGNORE_CRC Bit im Prozessor SSP Register setzt. Aus der Prozessor Referenz zum IGNORE_CRC Bit (übersetzt): "Im SPI/SSI Modus: Wenn auf 1 gesetzt, setze den Chip Select (SSn) Pin zurück nachdem das Kommando ausgeführt wurde".

Was also passiert wenn das CCW_WAIT4END nicht gesetzt wird, ist dass der Prozessor das Chip-Select zu früh ausschaltet.

Die DMA Übertragung die vom SPI Treiber in drivers/spi/spi-mxs.c initiiert wird setzt das neue MXS_DMA_CTRL_WAIT4END nicht, so dass das flag CCW_WAIT4END für die Übertragung nicht gesetzt wird.

Der Fix ist damit offensichtlich, wir müssen nur dieses Flag bei der Initiierung der DMA-Übertragung im SPI Treiber in drivers/spi/spi-mxs.c setzen.

Linux Kernel route_localnet Einstellung


Der Linux Kernel hat Einstellungen für Netzwerk Schnittstellen die es erlauben, das lokale Netz (127.0.0.0/8 für IP Version 4, auch oft Loopback Adresse bezeichnet weil sie typischerweise der lo Loopback Schnittstelle zugewiesen ist) zu routen. Diese Einstellung kann man über die Datei

/proc/sys/net/ipv4/conf/eth0/route_localnet

für die typische eth0 Netzwerk Schnittstelle erreichen. Die Einstellung kann man ändern, indem man dort entweder 0 (Standardeinstellung) für kein Routing oder 1 für Routing hineinschreibt. Einstellungen kann man bei den üblichen Linux Distributionen persistent machen, indem man einen Eintrag in /etc/sysctl.conf vornimmt. Dabei wird das Präfix /proc/sys/ vom obigen Pfad entfernt und alle / Zeichen durch einen Punkt ersetzt, das Resultat für obiges Beispiel wäre dann:

net.ipv4.conf.eth0.route_localnet = 1

Was kann man mit dieser Einstellung tun?

Im Folgenden verwende ich Beispiele für die IP Version 4, das gleiche kann man aber für die IP Version 6 local host Adresse ::1 erreichen.

Im Linux Netzwerk-Stack werden IP Pakete die entweder von einer Adresse aus dem lokalen Netz 127.0.0.0/8 kommen oder eine Zieladresse dort haben verworfen, wenn sie über eine Schnittstelle kommen die nicht für diesen Adressbereich konfiguriert ist. Wir verifizieren das im Folgenden zuerst mal.

Für einen einfachen UDP Server in Python der UDP Pakete empfängt und deren IP Adresse und Port (und Länge) ausdruckt können wir das folgende (gekürzte) Script verwenden:

import socket
[...]
sock = socket.socket (socket.AF_INET, socket.SOCK_DGRAM,
socket.IPPROTO_UDP)
sock.bind ((args.address, args.port))
while True:
    buf, adr = sock.recvfrom (1024)
    l = len (buf)
    print ('Received %(l)s bytes from %(adr)s' % locals ())

Alle Werte in der Variable args kommen von der Kommandozeile mit der Klasse ArgumentParser. Die Adresse auf die gebunden wird (bind) ist im folgenden standardmässig 0.0.0.0 was bedeutet dass Pakete von allen Netzwerk-Schnittstellen empfangen werden.

Wir testen das erstmal mit einem einfachen Client-Programm. Im folgenden haben wir zwei Maschinen, .9 sendet und .5 empfängt. Zum Versenden von UDP Paketen können wir den folgenden einfachen Code verwenden:

import socket
sock = socket.socket (socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
sock.sendto (b'This is a test', (args.address, args.port))

Auch hier werden die Parameter auf der Kommandozeile angegeben. Wenn wir von .9 an .5 senden bekommen wir vom obigen Server

Received 14 bytes from ('XXXXXXX.9', 38232)

wobei der der Quell-Port 38232 eine zufällige vom Linux Kernel erzeugte Portnummer ist und XXXXXXX die oberen Bytes der IP-Adresse des Senders repräsentiert. Wenn wir auf der empfangenden Maschine an 127.0.0.1 senden bekommen wir:

Received 14 bytes from ('127.0.0.1', 58196)

Wir sehen dass der Server sowohl auf der Ethernet Schnittstelle als auch auf der Loopback Schnittstelle empfängt.

Soweit nichts neues. Schauen wir mal was passiert wenn wir Adressen fälschen. Um beliebige IP Pakete (oder sogar Nicht-IP Pakete) zu erzeugen verwende ich im Folgenden das Python Paket Scapy.

Ein Ausschnitt aus dem Script:

p = Ether (dst = args.mac_address, src = args.mac_address) \
    / IP (dst = args.address, src = args.source_address) \
    / UDP (dport = args.port, sport = args.source_port) \
    / Raw (b'This is a test')
sendp (p, iface = args.interface)

Das baut ein Ethernet Paket (Ether) mit IP Inhalt. Das IP Paket wiederum enthält ein UDP Paket was wiederum einen Raw Zeichenfolge enthält. Das Paket wird über einen Netzwerk Layer 2 Socket versendet. Wiederum kommen die Parameter von der Kommandozeile. Standard für alle Ports ist 22222, die Quell-IP ist XXXXXXX.9 und die Ziel-IP ist XXXXXXX.5. Ziel- und Quell MAC Adresse werden beide auf die MAC-Adresse der Zielmaschine gesetzt. Wir wiederholen was wir mit dem einfachen Client ausprobiert haben:

sudo scapyclient.py

Was in der folgenden Ausgabe resultiert:

Received 14 bytes from ('XXXXXXX.9', 22222)

Hier wird standardmässig immer Port 22222 verwendet es sei denn wir setzen den Port über eine Kommandozeilen-Option des Scripts. Wir müssen sudo verwenden weil nur der Superuser beliebige Pakete auf Layer-2 des Netzwerk-Stacks senden darf.

Was passiert nun wenn wir Pakete fälschen? Damit wir sehen was gesendet (und empfangen) wird starten wir tcpdump wie folgt:

sudo tcpdump -n -i eth0 udp port 22222

Natürlich muss das Argument der -i Option geändert werden wenn die Netzwerk-Schnittstelle anders heisst als eth0. Die Option -n sagt tcpdump dass keine DNS-Auflösung von IP Adressen erfolgen soll. Wir senden ein Paket mit einer localhost Adresse als Quell-Adresse:

sudo scapyclient.py --source-address=127.0.0.1

Auf beiden Maschinen sehen wir in der tcpdump Ausgabe

TT:TT:TT.TTTTTT IP 127.0.0.1.22222 > 10.23.5.5.22222: UDP, length 14

wobei TT:TT:TT.TTTTTT ein Zeitstempel ist. Das Paket geht also auf der Ethernet-Schittstelle der sendenden Maschine raus und wird von der empfangenden Maschine empfangen (weil die MAC-Adresse richtig ist) aber von unserem Server wird nichts ausgegeben.

Das gleich passiert wenn wir die Zieladresse ändern:

sudo scapyclient.py --address=127.0.0.1

Die tcpdump Ausgabe ist jetzt:

TT:TT:TT.TTTTTT IP 10.23.5.9.22222 > 127.0.0.1.22222: UDP, length 14

Wieder wird das von beiden Maschinen von tcpdump ausgegeben aber nichts wird von unserem Server ausgegeben.

Schauen wir nun was passiert wenn wir route_localnet für unsere Ethernet Schnittstelle einschalten (das folgende funktioniert natürlich nur als root):

echo 1 > /proc/sys/net/ipv4/conf/eth0/route_localnet

Wir fälschen wieder die Quelladresse:

sudo python3.11 scapyclient.py --source-address=127.0.0.1

Wieder wird das Paket von beiden laufenden tcpdump Prozessen ausgegeben aber wieder nicht von unserem Server. Aber wenn wir die Zieladresse fälschen:

sudo python3.11 scapyclient.py --address=127.0.0.1

Bekommen wir zusätzlich zur tcpdump Ausgabe auch eine Antwort von unserem Server:

Received 14 bytes from ('10.23.5.9', 22222)

Und bemerkenswert ist, dass das auch funktioniert wenn wir unserem Server sagen dass er nur auf der localhost Adresse hören soll:

python3 server.py --address=127.0.0.1

Der Server empfängt trotzdem das gefälschte Paket obwohl es nicht über die Loopback Schnittstelle empfangen wurde:

Received 14 bytes from ('10.23.5.9', 22222)

Sicherheit

Manche Anwendungen verwenden lokale Server die nur auf die Loopback Schnittstelle hören. Es wird erwartet dass diese nur Pakete über die Loopback Schnittstelle empfangen – gesendet von Prozessen die auf der selben Maschine laufen wie der Server.

Dies ist immer eine gefährliche Annahme, es sollten nie gefährliche Dinge getan werden die sich auf diese (Nicht-) Authentifizierung verlassen. Zumindest unter Linux gibt es eine gewisse Sicherheit solange die Einstellung route_localnet auf 0 gesetzt ist. Auf anderen Betriebssystemen ist das Glück vielleicht nicht so hold. Und auch unter Linux kann dieses Verhalten ausgeschaltet werden.

Warum wollen wir diese Einstellung vornehmen?

Manchmal sollen Verbindungen die auf der lokalen Maschine zu einem Port auf der lokalen Maschine aufgebaut werden zu einer anderen Maschine verbunden werden, wenn z.B. /etc/hosts folgenden Eintrag hat

127.0.0.1 some.remote.example.com

und eine Anwendung zu dieser Maschine verbinden soll können wir Firewall Regeln konfigurieren die die Pakete so umschreiben dass das funktioniert:

iptables -t nat -A OUTPUT -d 127.0.0.1 -p tcp --dport 443 \
    -j DNAT --to-destination XXXXXXX.5:1443
iptables -t nat -A POSTROUTING -d XXXXXXX.5 -p tcp --dport 1443 \
    -j SNAT --to-source XXXXXXX.9

Die erste Regel schreibt die Zieladresse um (und den Zielport) während die zweite Regel sicherstellt dass Antwortpakete ankommen indem die Quelladresse umgeschrieben wird. Aber ohne die Einstellung route_localnet für die ausgehende Ethernet-Schnittstelle wird das nicht funktionieren, die Pakete werden verworfen.

Aber Vorsicht: Weil diese Einstellung auch auf die empfangenen Pakete einen Einfluss hat ist zu beachten was im Abschnitt Sicherheit beschrieben wurde.

Optimierung von Fliesskomma-Problemen mit Evolutionären Algorithmen


[Dies ist ein erweiterter Abstract für einen geplanten Vortrag]

Evolutionäre Algorithmen (EA) sind ein Oberbegriff für Genetische Algorithmen (GA) und Verwandte Algorithmen. GA arbeiten üblicherweise mit Bits oder kleinen Integer-Zahlen.

Es gibt andere EA die direkt mit Fliesskomma-Zahlen arbeiten können, unter ihnen Differential Evolution (DE) [1] [2] [3].

Der Vortrag gibt eine Einführung in die Optimierung von Fließkomma Problemen anhand von Beispielen aus der Elektrotechnik sowie der Optimierung von Kurvenformen zur Ansteuerung von piezoelektrischen Inkjet Druckern. Bei diesen Druckern hängt die Form des gejetteten Tropfens (neben anderen Parametern wie der gejetteten Flüssigkeit) von der zur Ansteuerung verwendeten Kurvenform ab. Diese bestimmt die Qualität der gejetteten Tropfen.

Für die Software verwende ich die Python Bindings PGAPy [4] für das ursprünglich an den Argonne National Laboratories entwickelte "Parallel Genetic Algorithm Package" PGAPack [5]. Beide Open Source Pakete maintaine ich seit einigen Jahren. Unter anderen wurde diverse Algorithmen wie Differential Evolution und Strategien zur Optimierung von Multi-Objective Optimization (also Problemen mit mehreren Zielfunktionen mit NSGA-II [6]) neu implementiert.

Differential Evolution veranschaulicht


Dieser Beitrag bezieht sich auf PGAPack und PGApy oder andere Genetische Algorithmus (GA) Implementierungen die Differential Evolution (vielleicht übersetzbar mit Differentielle Evolution, in der Folge abgekürzt mit DE) unterstützen.

Differential Evolution (DE) [1], [2], [3] ist ein Optimierungsalgorithmus der ähnlich wie andere evolutionäre Algorithmen auf einer Population basiert. Der Algorithmus ist recht mächtig, in PGAPack implementiert und auch in PGAPy nutzbar. Um einige Punkte zu diesem Algorithmus zu veranschaulichen habe ich einen einfachen Parcours in OpenSCAD konstruiert.

 

/images/parcours.gif

 

Um den Optimierer mit diesem Parcours zu testen ist das Gen (DE nennt es die Parameter) die X- und Y-Koordinate um eine Position im Parcours zu bestimmen. In der Folge bezeichnen wir diese zwei Werte auch als Vektor, wie das in der Literatur zu DE üblich ist. Wir initialisieren die Population in der Gegend beim Start der Rampe. Wir erlauben der Population, den Initialisierungsbereich zu verlassen.

Es ist zu beachten, dass die Ecken des Parcours flach sind, dass dort also alle Punkte die selbe Höhe haben. Solche Bereiche sind generell schwierig für Optimierungsalgorithmen weil der Algorithmus nicht wissen kann, in welche Richtung bessere Werte (wenn es diese überhaupt gibt) zu finden sind. Dies ist auch der Grund, warum wir den Initialisierungsbereich nicht in den flachen Bereich am Boden des Parcours legen. Üblicherweise würde man nämlich die Population über den gesamten zu durchsuchenden Bereich initialisieren. In diesem Fall möchten wir demonstrieren, dass der Algorithmus auch fähig ist, eine Lösung weit vom Initialisierungsbereich zu finden.

Der DE Algorithmus ist recht einfach (siehe z.B. [3] S.37-47): Mit jedem Vektor \(\mathbf x_{i,g}\) der Population der aktuellen Generation \(g\), wobei \(i\) der Populationsindex ist wird ein Genaustausch mit einem Mutanten-Vektor vorgenommen. Der Mutanten-Vektor wird zufällig (ohne Zurücklegen) aus drei Populations-Vektoren bestimmt, indem diese wie folgt kombiniert werden.

\begin{equation*} \mathbf v_{i,g} = \mathbf x_{r_0,g} + F \cdot (\mathbf x_{r_1,g} - \mathbf x_{r_2,g}) \end{equation*}

Wie man sieht, wird eine gewichtete Differenz von zwei Vektoren der Population gebildet und zu einem dritten Vektor addiert. Alle Indices \(r_0, r_1,\) und \(r_2\) sind voneinander und von \(i\) verschieden. Das Gewicht \(F\) ist ein Konfigurationsparameter der typischerweise \(0.3 \le F < 1\) ist. Der beschriebene Algorithmus ist der klassische DE Algorithmus, oft auch als de-rand-1-bin bezeichnet. Eine Variante nutzt statt einem zufälligen Index \(r_0\) den Index des besten Vektors und wird als de-best-1-bin bezeichnet. Falls mehr als zwei Differenzen addiert werden wäre eine andere Variante z.B. de-rand-2-bin. Die letzte Komponente der Bezeichnung steht für Uniform Crossover [4], eine binomiale Verteilung. In DE werden die Parameter des Mutanten-Vektors mit einer bestimmten Wahrscheinlichkeit \(Cr\) ausgewählt. Man beachte, dass DE dafür sorgt, dass zumindest ein Parameter des Mutanten-Vektors ausgewählt wird (sonst bliebe der betrachtete Original-Vektor \(\mathbf x_{i,g}\) unverändert).

Mehr Details zu DE und er Nutzung mit PGAPack findet sich in den Abschnitten Differential Evolution und Mutation der PGAPack Dokumentation.

Um das OpenSCAD Modell in der Optimierung zu verwenden, wird es mithilfe eines STL zu PNG Konverters in ein Graustufen-Höhenbild umgewandelt (STL ist ein 3D-Format das von OpenSCAD generiert werden kann). Die Bewertungsfunktion des Algorithmus liefert dann einfach den Graustufen-Wert an der gegebenen X/Y-Position zurück (nach Rundung der Gen-Werte und Konvertierung in einen Integer-Wert). Werte ausserhalb des Bildes werden als 0 (schwarz) geliefert. Der resultierende Optimierungslauf ist unten in einem animierten Bild dargestellt. Die Population ist ziemlich klein und enthält nur 6 Vektoren. Wir sehen dass, wenn die Abstände zwischen den Punkten groß werden (auf den geraden Strecken des Parcours), die Optimierung in größeren Schritten voranschreitet. Wir sehen auch, dass der Algorithmus in den flachen Ecken einige Zeit brauchen kann bis er aus diesem Bereich entkommt. Schließlich, in der letzten Ecke, wird der Kegel erklommen und der Algorithmus stoppt wenn das erste Individuum eine Bewertung mit dem größten Graustufen-Wert liefert (entspricht weiss). Bitte beachten, dass ich ein bisschen geschummelt habe: Viele Optimierungläufe dauern deutlich länger (Insbesondere in den flachen Bereichen kann der Algorithmus längere Zeit stecken bleiben). Ich habe einen Lauf ausgewählt der sich für eine Animation gut eignet. Dieser Lauf ist nicht im Bereich des Durchschnitts sondern deutlich kürzer.

 

/images/animated.gif

 

Notizen zur Vorzeitigen Konvergenz in Genetischen Algorithmen


Dieser Beitrag beschäftigt sich wieder mit PGAPack und PGApy, sowie anderen Implementierungen von Genetischen Algorithmen (GA).

Bei der Suche von Lösungen mit einem GA passiert es, dass eine sub-optimale Lösung gefunden wird, weil die Population frühzeitig in einem Bereich des Suchraumes konvergiert wo keine besseren Lösungen mehr gefunden werden. Dieser Effekt heisst vorzeitige Konvergenz.

Es ist normalerweise schwierig, vorzeitige Konvergenz zu entdecken. Eine gute Metrik ist die mittlere Hamming Distanz zwischen Individuen. In PGAPack kann man Auswertungen der Hamming Distanz über die Option PGA_REPORT_HAMMING der Funktion PGASetPrintOptions aktivieren. In PGApy geht das mit dem Konstruktor Parameter print_options. Leider ist dies nur für den binären Datentyp implementiert.

Ein möglicher Grund für das Auftreten von vorzeitiger Konvergenz ist die Verwendung von proportionaler Selektion wie in einem früheren Artikel in diesem Blog [1] ausgeführt. Wenn während der Frühphase der Suche ein Individuum gefunden wird das viel besser ist als alles bisherige, so ist die Chance groß, dass dieses Individuum die Population übernimmt wenn proportionale Selektion verwendet wird, wodurch ein weiterer Fortschritt des Algorithmus verhindert wird. Der Grund dafür ist, dass ein Individuum das wesentlich besser ist als alle anderen einen unangemessen hohen Anteil des (gedachten) Roulette-Rades einnimmt wenn Individuen für die nächste Generation ausgewählt werden, was dazu führt, dass alles andere genetische Material nur eine geringe Chance hat, in die nächste Generation zu kommen.

Ein anderer Grund für vorzeitige Konvergenz kann eine zu kleine Populationsgröße sein. Goldberg et. al. [9] geben Abschätzungen der Populationsgröße für einen klassischen GA mit kleinem Alphabet (mögliche verschiedene Allele, also 0 und 1 für einen binären GA) der Kardinalität \(\chi\) an. Dabei wird eine maximale Länge einer Symbolfolge (Building Block) angenommen die irreführende Symbolfolgen (deception) überwindet mit \(k \ll n\) wobei \(k\) die Symbolfolgen-Länge (ein Maß für die Schwierigkeit des Problems) und \(n\) die Gen-Länge ist. Es wird gezeigt, dass die Populationsgröße \(O(m\chi^k)\) sein sollte mit \(m=n/k\) so dass mit einer vorgegebenen Schwierigkeit \(k\) die Populationsgröße proportional zur Gen-Länge \(n\) ist. Dieses Ergebnis kann aber nicht auf Probleme mit einem sehr großen Alphabet wie Fließkomma-Genen wie dem real Datentyp von PGAPack verallgemeinert werden. Für Fließkomma Darstellungen kann man die Schwierigkeit üblicherweise einschätzen wenn man die Multimodalität, also die Anzahl der Gipfel oder Täler (jenachdem ob es sich um ein Maximierungs- oder Minimierungsproblem handelt) der Zielfunktion betrachtet.

Wenn mit einer angemessenen Populationsgröße und einem sinnvollen Selektionsmechanismus noch immer vorzeitige Konvergenz auftritt, können einige andere Mechanismen zur Anwendung kommen.

Vermeidung von Duplikaten

PGAPack implementiert einen Mechanismus zur Vermeidung von doppelt vorkommenden Genen (Individuen). In früheren Implementierungen war der Aufwand dafür quadratisch in der Populationsgröße \(N\), also \(O(N^2)\) (es wurde jedes neue Individuum mit allen Mitgliedern der aktuellen Population verglichen). In der aktuellen Version wird eine Hashfunktion verwendet um Duplikate zu entdecken, was den Aufwand auf \(O(N)\) reduziert, also einen kleinen konstanten Aufwand für jedes neue Individuum.

Für benutzerdefinierte Datentypen bedeutet das, dass eine Hash Funktion für den Datentyp gebraucht wird, zusätzlich zu einer Gen-Vergleichsfunktion. Für die eingebauten Datentypen (binary, integer, real) sind diese Funktionen automatisch verfügbar.

Die Vermeidung von Duplikaten funktioniert gut für Integer und Binäre Datentypen, besonders wenn die verwendeten genetischen Operatoren mit hoher Wahrscheinlichkeit Duplikate erzeugen können. Es funktioniert nicht so gut mit dem real Datentyp weil Individuen normalerweise unterschiedlich sind, auch wenn sie genetisch sehr nah beieinanderliegen.

Restricted Tournament Replacement

Ein Algorithmus der ursprünglich von Harik [2], [3] Restricted Tournament Selection genannt wurde und später von Pelikan [4] mit Restricted Tournament Replacement (RTR) bezeichnet wurde, verwendet die alte und die neue Population um zu entscheiden, ob ein Kandidat in die neue Population aufgenommen wird. Der Algorithmus wählt eine Anzahl von Individuen der alten Population (das sogenannte Selektionsfenster) zufällig aus und vergleicht die Bewertung des Indivuums welches genetisch dem Kandidaten-Individuum am nächsten ist. Nur wenn das Kandidaten-Individuum besser ist als das genetisch ähnlichste aus dem Selektionsfenster wird es in die neue Generation übernommen.

Die Standardeinstellung für die Anzahl der Individuen im Selektionsfenster ist \(\min (n, \frac{N}{20})\) [4] wobei \(n\) die Genlänge und \(N\) die Populationsgröße ist. Dies kann vom Benutzer geändert werden durch Aufruf der Funktion PGASetRTRWindowSize für PGAPack bzw. mit dem Konstruktor-Parameter rtr_window_size für PGApy.

Der RTR-Algorithmus braucht eine Ähnlichkeitsmetrik um zu entscheiden, wie ähnlich ein Individuum einem anderen ist. Die Vorgabe ist die Manhattan Distanz (für binäre Gene identisch mit der Hamming Distanz), also die Summe aller einzelnen Allele-Differenzen. Dies kann auf die Euclidsche Distanz geändert werden durch Setzen einer benutzerdefinierten Funktion für die genetische Distanz. Die Verwendung einer Euclidschen Ähnlichkeitsmetrik sieht man im Beispiel: Magisches Quadrat in PGApy wo man die Euclidsche Metrik über eine Kommandozeilenoption auswählen kann.

Restricted Tournament Replacement funktioniert nicht nur für binäre und Integer Gene gut sondern auch für den real Datentyp. Man kann diesen Ersetzungsmechanismus mit anderen Einstellungen des GA kombinieren.

Die Auswirkung von RTR auf ein Problem das zu vorzeitiger Konvergenz tendiert kann man im Test-Programm examples/mgh/testprog.f sehen. Dieses Programm implementiert einige Testfunktionen aus einem alten Benchmark für nichtlineare Optimierungsalgorithmen [5]. Die Testfunktion mit Tendenz zu vorzeitiger Konvergenz ist was die Autoren eine "gausssche Funktion" nennen, beschrieben im Beispiel (9) im Artikel [5] und implementiert als Funktion 3 in examples/mgh/objfcn77.f. Diese Funktion wird angegeben als

\begin{equation*} f(x) = x_1 \cdot e^{\frac{x_2(t_i - x_3)^2}{2}} - y_i \end{equation*}

mit

\begin{equation*} t_i = (8 - i) / 2 \end{equation*}

Und tabellierten Werten für \(y_i\) im Artikel bzw. der Implementierung in examples/mgh/objfcn77.f. Das Minimierungsproblem aus diesen Gleichungen ist

\begin{equation*} f (x) = \sum_{i=1}^{m} f_i^2(x) \end{equation*}

mit \(m=15\) für dieses Testproblem. Die Autoren [5] geben den Vektor \(x_0 = (0.4, 1, 0)\) für das Minimum \(f(x_0) = 1.12793 \cdot 10^{-8}\) an. Die Original Fortran-Implementierung in examples/mgh/testprog.f verwendet eine Populationsgröße von 10000 mit den Standardeinstellungen für den real Datentyp von PGAPack. Die große Populationsgröße wird gewählt weil das Problem sonst vorzeitig konvergiert. Nach 100 Generationen wird die Lösung \(x_0=(0.3983801, 0.9978369, 0.009918243)\) mit einem Evaluationswert von \(f(x_0)=2.849966\cdot 10^{-5}\) gefunden. Die Anzahl der Funktionaufrufe der Zielfunktion waren 105459 (etwas weniger als \(10000 + 100 \cdot 1000\) also Bewertung der initialen Population plus etwa 10% der Population in jeder Generation, die Wahrscheinlichkeit für Crossover und Mutation ist nicht 100%, daher kommt es vor dass keiner der Operatoren angewandt wird und die Evaluationsfunktion daher nicht aufgerufen wird)

Ich habe das gleiche Problem mit Differential Evolution [6], [7], [8] in examples/mgh/testprogde.c implementiert (das Treiberprogramm ist in C geschrieben weil ich nicht wirklich Fortran spreche, verwendet aber die gleichen Funktionen wie das Fortran Treiberprogramm und linkt Fortran und C Code in einem gemeinsamen ausführbaren Programm). Es verwendet eine Populationsgröße von 2000 (was für Differential Evolution groß ist wieder aus Gründen von vorzeitiger Konvergenz) und findet die Lösung \(x_0=(0.3992372, 0.9990791, -0.0007697923)\) mit einer Bewertungsfunktion von \(f(x_0)=7.393123\cdot 10^{-7}\) in nur 30 Generationen. Dies entspricht 62000 Funktionsaufrufen der Zielfunktion (Differential Evolution berechnet eine komplette Generation und entscheidet dann welche Individuen in die neue Generation übernommen werden).

Wenn man RTR für dieses Problem verwendet wie in examples/mgh/testprogdertr.c, kann die Populationsgröße auf 250 reduziert werden und sogar nach 100 Generationen konvergiert die Suche nicht vorzeitig auf eine suboptimale Lösung. Nach 100 Generationen finden wir \(x_0=(0.398975, 1.000074, -6.719886 \cdot 10^{-5})\) und \(f(x_0)=1.339766\cdot 10^{-8}\) (allerdings mit ein paar anderen Einstellungen für den Differential Evolution Algorithmus). Dies entspricht 25250 Funktionsaufrufen der Zielfunktion.

Restart

Das letzte Mittel wenn die obigen Mechanismen versagen ist ein regelmässiger Restart des GA wann immer die Population zu stark konvergiert. Der Restart-Mechanismus in PGAPack verwendet das beste Individuum der Population um eine neue Population mit durch Mutation erzeugten Varianten dieses Individuums zu initialisieren. Restarts kann man mit PGASetRestartFlag in PGAPack oder dem restart Konstruktor Parameter in PGApy einschalten. Die Frequenz der Restarts (Standard ist alle 50 Generationen) kann mit PGASetRestartFrequencyValue in PGAPack und dem restart_frequency Konstruktor Parameter in PGApy gesetzt werden.

Proportionale Selection (Roulette-Rad) in Genetischen Algorithmen


Einige Leser hier wissen dass ich PGApack, ein Paket für Genetische Algorithmen, zusammen mit PGApy einer Python Bibliothek pflege (maintaine). Vor kurzem kam die Frage auf, warum PGApack wenn eine Selektionsmethode verwendet wird die proportional zur Fitness selektiert (Fitness Proportionate Selection), im englischen auch als Roulette-Rad Selektion bezeichnet, mit einem Fehler aussteigt weil es einen Fitness-Wert nicht normalisieren kann. Im folgenden werde ich kurz von proportionaler Selektion schreiben.

In PGApack definieren wir als Anwenderin eine Reelle Bewertungsfunktion (die für jedes Individuum aufgerufen wird). Ausserdem definieren wir ob diese Bewertung minimiert oder maximiert wird (wir legen die Optimierungsrichtung fest).

Bei proportionaler Selektion muss diese Bewertung auf positive Werte abgebildet werden so dass jeder Bewertung ein Teil des Roulette-Rades zugewiesen wird. Wenn minimiert wird sind kleine Werte besser und müssen einen größeren Bereich des Roulette-Rades bekommen so dass die Wahrscheinlichkeit der Selektion höher ist. Wir müssen also die Bewertungsfunktion auf eine nicht-negative monoton steigende Fitness abbilden. Bei einem Minimierungsproblem berechnen wir die maximale (schlechteste) Bewertung und bilden dann die Differenz dieses Maximums mit der Bewertung des Individuums (nach einer Skalierung des Maximums so dass kein Fitnesswert negativ wird):

\begin{equation*} F = E_{max} - E \end{equation*}

wobei \(F\) die Fitness des aktuellen Individuums ist, \(E_{max}\) das Maximum aller Bewertungen der aktuellen Generation und \(E\) die Bewertung des Individuums.

Wenn sich nun die Bewertungen um mehrere Größenordnungen unterscheiden kann es vorkommen, dass die Fitness zu \(E_{max}\) wird, für viele (unterschiedliche) Individuen. Ich nenne das einen Überlauf (overflow), vielleicht nicht der beste Name dafür.

Der Überlauf passiert wenn \(E_{max}\) groß ist verglichen mit der aktuellen Bewertung \(E\) so dass die obige Differenz \(E_{max}\) wird (die Subtraktion von \(E\) also keine Wirkung hat) Im Code checken wir diese Bedingung:

if (cmax == cmax - evalue && evalue != 0)

Die Bedingung ist erfüllt wenn die Subtraktion von \(E\) von \(E_{max}\) \(E_{max}\) nicht verändert obwohl \(E\) ungleich Null ist. \(E\) ist so klein gegenüber \(E_{max}\) dass der Datentyp double den Unterschied nicht repräsentieren kann. Das passiert wenn die Einheit der letzten Stelle (units in the last place von Goldberg (nicht dem Goldberg der das Genetische Algorithmen Buch geschrieben hat soweit ich weiss) auch ulps genannt) der Mantisse größer ist als der Wert der subtrahiert wird [1].

In unserem Beispiel \(E_{max} = 1.077688 * 10^{22}\) und die Bewertung bei der das schiefging war \(E = 10000\). Ein IEEE 754 Fließkomma-Wert doppelter Genauigkeit (double) hat 53 bit Mantisse die damit Zahlen bis \(2^{54} - 1 = 18014398509481983\) darstellen kann, also etwa \(1.8 * 10^{16}\). Man sieht dass die Zahl 10000 gerade unterhalb des oben erwähnten ulps ist. Wir können das in Python ausprobieren (das für Fließkommazahlen double verwendet):

>>> 1.077688e22 - 10000 == 1.077688e22
True

Warum machen wir diesen Check im Programm? Würde die Suche bei einem solchen Überlauf (oder wie auch immer wir das nennen wollen) weiterlaufen, würde der Algorithmus viele verschiedene Bewertungen auf die gleiche Fitness abbilden. Der Genetische Algorithmus könnte also diese Individuen nicht unterscheiden.

Was können wir tun wenn dieser Fehler auftritt?

Die kurze Antwort: Einen anderen Selektionsmechanismus verwenden. Es gibt einen Grund, warum proportionale Selektion nicht die Standardeinstellung in PGApack ist.

Proportionale Selektion (Fitness proportionate selection) hat noch andere Probleme. Sie hat zu großen Selektionsdruck am Anfang zu wenig gegen Ende der Optimierung (auch erwähnt im obigen Wikipedia Artikel, aber Vorsicht, ich habe Teile davon geschrieben).

Blickle and Thiele [2] haben eine mathematische Analyse von unterschiedlichen Selektions-Algorithmen publiziert und gezeigt dass proportionale Selektion typischerweise keine gute Idee ist (proportionale Selektion war historisch der erste Selektionsmechanismus in der Literatur und war ausführlich in Goldbergs (der andere Goldberg) Buch [3] beschrieben und ist vielleicht deshalb heute noch manchmal in Verwendung). Es sei darauf hingewiese dass Blickle und Thiele in einem früheren Report [4] direkter waren was die Beurteilung von proportionaler Selektion betrifft (meine Übersetzung): "Alle unerwünschten Eigenschaften zusammen führten uns zu dem Schluss dass proportionale Selektion ein sehr ungeeigneter Selektionsmechanismus ist. Informell könnten wir sagen dass der einzige Vorteil von proportionaler Selektion ist, dass es so schwierig ist die Nachteile zu beweisen" ([4], S. 42), in der Endfassung im veröffentlichten Artikel waren sie dann nicht mehr so deutlich :-)

Wir sehen in obigem Beispiel: Wir haben sehr große Unterschiede zwischen guten und schlechten Bewertungen (eben so groß dass die Fitness nicht eindeutig berechnet werden kann, s.o.). Wenn man dafür proportionale Selektion verwendet, werden sehr gute Individuen mit zu großer Wahrscheinlichkeit selektiert was zu verfrühter Konvergenz (premature convergence) führt.

Zum Schluss all dieser Ausführungen: Wenn wir Optimierungen mit Fließkomma-Repräsentationen durchführen (diese werden durch double Datentypen in PGApack repräsentiert) sollten wir Differential Evolution ausprobieren [5], [6], [7]. Zumindest in meinen Experimenten mit Antennen-Optimierung [8] sind die Resultate damit deutlich besser als mit standard Genetischen Algorithmen was auch von verschiedenen Praktikern bestätigt wird [7]. Beispiele dazu finden sich in examples/deb/optimize.c oder examples/mgh/testprogde.c in PGApack.

Der Parameter PGASetDECrossoverProb ist kritisch. Für Probleme wo die Dimensionen nicht einzeln optimierbar sind, sollte der Wert nahe bei, aber nicht gleich 1 sein.

Antennendiagramme plotten


Für mein pymininec Projekt (eine Re-Implementierung des Original-Mininec [1] in Python, siehe die Beschreibung dort wie es möglich war, 40 Jahre alten Basic-code zu Reverse-Engineeren) hatte ich eine Möglichkeit gebraucht, die Resultate graphisch darzustellen. Daher hatte ich zuerst ein Visualisierungswerkzeug in pymininec inkludiert.

Nachdem ich dann aber auf die Schnelle einen Parser für die Ausgabe von nec2c geschrieben hatte, stellte sich heraus, dass das Programm auch allein nützlich ist. Daher habe ich es als eigenes Projekt plot-antenna genannt.

NEC (vielleicht übersetzbar mit numerischer elektromagnetischer Code) wurde ursprünglich am Lawrence Livermore National Laboratory in Fortran entwickelt. Bis Version 2 ist das freie Software, spätere Versionen sind nicht frei (inzwischen ist NEC bei Version 5). Neoklis Kyriazis hat die Fortran Version von NEC-2 nach C übersetzt und das Resultat nec2c genannt. Er hat einige Optimierungen vorgenommen, z.B. das ursprünglich separate Programm SOMNEC (um ein Sommerfeld/Norton Erdungsmodell zu berechnen) integriert und einige Limitierungen des ursprünglichen Fortran-Codes beseitigt. Das Programm nec2c ist eines von wenigen Open Source Antennen-Modellierungsprogrammen. Es ist ein Kommandozeilen-Programm das aus der Beschreibung einer Antenne eine Ausgabedatei mit vielen Tabellen berechnet. Es gibt andere Programme wie xnecview die mit dieser Ausgabedatei eine Antennen-Visualisierung erzeugen können.

An dieser Stelle ist zu bemerken, dass ich keine offizielle Webseite von nec2c gefunden habe. Das Programm ist in Debian und Ubuntu (Ubuntu sucht auch nach der offiziellen Upstream-Version) enthalten und es scheint die ursprüngliche Version war mal auf Google Code gehostet. Es gibt eine Version auf Github die auf eine andere Version auf Github verweist. Die Software scheint heutzutage nicht gewartet zu werden – kein großes Problem weil auch NEC in Version 2 seit ein paar Dekaden keine Updates mehr bekommt.

Zurück zu plot-antenna: Ursprünglich hatte das Programm nur einen Parser für die Ausgabe von pymininec (und für das Original Mininec [1] Basic-Programm). Später habe ich dann einen Parser für NEC hinzugefügt. Die derzeitigen Parser sind wahrscheinlich nicht sehr robust, funktionieren aber für meine Zwecke.

Die erste Version verwendet Matplotlib für die Grafikausgabe und diese Variante ist immer noch unterstützt. Urprünglich hatte ich Azimuth- und Elevationsdiagramme, sowie eine 3D-Anzeige implementiert.

Kürzlich, nachdem ich einige Zeit vorher Plotly entdeckt hatte, habe ich HTML/Javascript Ausgabe mithilfe von Plotly hinzugefügt. Die 3D Möglichkeiten von Plotly sind deutlich besser als die von Matplotlib – sogar mit 1 Grad Auflösung bei den Antennen-Winkeln (Azimuth/Elevation), also 180 * 360 3D Punkten, funktioniert das Display im Browser (Firefox). Mit der 3D Variante von Matplotlib muss 5 Grad Auflösung verwendet werden (36 * 72 3D Punkte), sonst ist die Anzeige sehr ruckelig. Mit der folgenden Grafik kann selbst damit experimentiert werden (kann auch in einem vollen Browserfenster dargestellt werden):

Nicht zu reden von Zoom: Das funktioniert in Plotly ausgezeichnet (mit dem Scroll-Rad der Maus in obiger Grafik) und scheint in Matplotlib garnicht implementiert zu sein.

In der Plotly Version zeichnen wir die Visualisierung für alle Frequenzen in eine Grafik. Für die 3D-Ansicht sind alle Visualisierungen bis auf die der gerade selektierten Frequenz versteckt. Über die Frequenz-Legende auf der rechten Seite kann die jeweils anzuzeigende Frequenz ausgewählt werden.

Eine Besonderheit der Plotly Version ist die Anzeige der 3D-Koordinaten auf der Oberfläche der 3D-Visualisierung beim Drüberfahren mit der Maus. In der derzeitigen Implementierung werden dabei Azimuth und Elevations-Winkel sowie absoluter und relativer Gewinn für den gerade ausgewählten Punkt angezeigt.

Allerdings habe ich mit Plotly auch einige Workarounds gebraucht:

  • Es gibt einen Bug im Koordinatendisplay

  • Plotly unterstützt kein Menü (Legende) bei 3D Oberflächen, Ich musste eine zusätzliche (leere mit einem weissen Pixel) Grafik in die Anzeige aufnehmen um Menü (Legende) anzuzeigen. Das Menü wird verwendet um auf der rechten Seite die Anzeige für eine bestimmte Frequenz auszuwählen.

  • In Plotly gibt es keine Möglichkeit, nur einen bestimmten Menüeintrag gleichzeitig anzuzeigen. Ich wollte dass sich die Menü-Einträge wie Radio-Knöpfe (Radio-Buttons) verhalten: Beim Klicken auf eine Frequenz sollte die Visualisierung von nur dieser Frequenz erfolgen. Für 3D Grafiken macht es nicht viel Sinn, mehrere 3D Visualisierungen in einer Anzeige zu haben. Dies wird mit ein paar Zeilen individuellem Javascript erreicht.

Das Antennendiagramm in obigem Beispiel ist aus einem Artikel von L.B. Cebik [2]. Beim Vergleich des Diagramms im Artikel mit obiger 3D-Ansicht fällt auf (auch ein Vergleich der Azimuth/Elevationsdiagramme in der Beschreibung von plot-antenna ist möglich) dass die Plotly 3D Ansicht leicht verzerrt ist (die Keule ist zu lang). Ich habe noch nicht rausgefunden ob das ein Problem von Plotly oder meiner Verwendung davon ist. Ich versuche das gleiche Bildseitenverhältnis für alle Richtungen einzustellen indem ich den Wertebereich (range) für xaxis, yaxis und zaxis einstelle aber es schaut nicht korrekt aus.

Beim Vergleich mit dem Azimuth Diagramm welches mit Plotly erzeugt wurde:

sehen die Größenverhältnisse richtig aus. Auch im 2D-Diagramm wird der Winkel (Azimuth in diesem Fall) beim Überfahren mit der Maus sichtbar. Auch mit Matplotlib ist dies möglich, jedoch rastet der Cursor nicht auf den jeweiligen Punkt auf der Anzeige ein. Damit kann mit Matplotlib nur geraten werden ob sich der Cursor an der korrekten Stelle befindet. Weiters kann wie bei der 3D-Grafik die Frequenz für die Anzeige rechts in der Legende ausgewählt werden. Diesmal ist die gleichzeitige Anzeige von mehreren Frequenzen möglich, was bei einer opaken 3D-Ansicht nicht sehr sinnvoll wäre.

Optimierung mit Epsilon-Randbedingungen


[Änderung 2022-10-18: Ersetze epsilon durch delta in der Beschreibung von Beispiel 7 in pgapack]

Viele Optimierungsprobleme beinhalten Randbedingungen die von gültigen Lösungen erfüllt sein müssen. Ein Optimierungsproblem mit Randbedingungen wird typischerweise als nichtlineares Programmierungs-Problem formuliert [1].

\begin{align*} \hbox{Minimiere} \; & f_i(\vec{x}), & i &= 1, \ldots, I \\ \hbox{Unter den Bedingungen} \; & g_j(\vec{x}) \le 0, & j &= 1, \ldots, J \\ & h_k(\vec{x}) = 0, & k &= 1, \ldots, K \\ & x_m^l \le x_m \le x_m^u, & m &= 1, \ldots, M \\ \end{align*}

In diesem Problem gibt es \(n\) Variablen (der Vektor \(\vec{x}\) hat die Länge \(n\)), \(J\) Ungleich-Bedingungen, \(K\) Gleichheits-Bedingungen und die Variable \(x_m\) muss im Bereich \(|x_m^l, x_m^u|\) sein (als Box-Bedingung bezeichnet). Die Funktionen \(f_i\) heissen Zielfunktionen. Probleme mit mehreren Zielfunktionen habe ich schon früher in diesem Blog [2] beschrieben. Im folgenden werde ich einige Begriffe ohne weitere Erklärung verwenden die in diesem früheren Blogbeitrag eingeführt wurden.

Die Zielfunktionen werden nicht notwendigerweise minimiert (wie in der Formel angegeben) sondern können auch maximiert werden wenn das Problem dies verlangt. Die Ungleich-Bedingungen werden auch oft mit einem \(\ge\) Zeichen formuliert, die Formal kann einfach umgestellt werden (z.B. durch Multiplikation mit -1) um ein \(\le\) Zeichen zu verwenden.

Nachdem es recht schwer ist, Gleichheits-Bedingungen zu erfüllen, besonders wenn diese in nichtlinearen Funktionen der Eingabevariablen auftreten, werden Gleichheits-Bedingungen oft in Ungleich-Bedingungen umgewandelt unter Verwendung einer δ‑Umgebung:

\begin{equation*} -\delta \le h_k(\vec{x}) \le \delta \end{equation*}

Wobei δ so gewählt wird dass die Lösung gut genug für das zu lösende Problem ist.

Eine sehr erfolgreiche Methode zur Lösung von Problemen mit Randbedingungen verwendet eine lexicographische Ordnung von Randbedingungen und Zielfunktion(en). Lösungskandidaten des Problems werden zuerst nach verletzten Randbedingungen sortiert (typischerweise der Summe der Verletzung von Randbedingungen) und dann nach dem Wert der Zielfunktion(en) [1]. Beim Vergleich von zwei Individuen während der Selektionsphase des Genetischen Algorithmus gibt es drei Fälle: Wenn beide Individuen die Randbedingungen verletzen gewinnt das Individuum mit der kleineren Verletzung der Bedingungen. Wenn ein Individuum keine Bedingung verletzt, das andere aber schon, gewinnt das Individuum ohne Verletzung von Randbedingungen. Im letzten Fall dass keines der Individuen die Bedingungen verletzt, findet ein normaler Vergleich der Zielfunktionen statt (was vom verwendeten Algorithmus abhängt und ob maximiert oder minimiert wird). Diese Methode, ursprünglich von Deb [1] vorgeschlagen ist in PGAPack und im Python Wrapper PGAPy implementiert.

Mit diesem Algorithmus zur Behandlung von Randbedingungen, werden zuerst Lösungskandidaten gefunden die keine Randbedingung verletzen bevor der Algorithmus die Zielfunktionen überhaupt "anschaut". Daher passiert es dann oft, dass die Suche in einer Region endet wo es keine guten Lösungen gibt (aber dafür keine Randbedingungen verletzt sind). Schwierige Probleme, bei welchen dies auftritt, sind oft Probleme mit Gleichheits-Bedingungen aber es gibt auch andere "schwierige" Randbedingungen. In einem früheren Blog Post [2] zur Antennen-Optimierung habe ich geschrieben: "dass der Optimierungs-Algorithmus Schwierigkeiten hat, überhaupt sinnvolle Lösungen für die Direktor-Variante zu finden. Nur bei einer handvoll Experimente war es überhaupt möglich, die obige Pareto-Front zu finden."

In diesem Experiment habe ich 50 Optimierungsläufe durchgeführt und nur 5 davon sind nicht in einem lokalen Optimum steckengeblieben. Etwas ähnliches passiert bei Problem 7 in Deb's Artikel [1] wo Gleichheits-Bedingungen verwendet werden. Ich habe dieses als Beispiel 7 in PGAPack implementiert. Es wird nur eine Lösung nahe dem (bekannten) Optimum gefunden wenn \(\delta \ge 10^{-2}\) für alle Gleichheits-Bedingungen ist (ich habe nicht mit verschiedenen Anfangswerten für den Zufallszahlengenerator experimentiert, es könnte sein dass man mit einem anderen Startwert bessere Lösungen findet). Im Artikel [1] verwendet Deb \(\delta = 10^{-3}\) aus dem selben Grund.

Eine Methode, um dieses Problem zu verbessern, war attraktiv, weil sie sowohl einfach zu verstehen als auch zu implementieren ist: Takahama und Sakai haben zuerst mit einer Methode experimentiert um unter gelockerten Randbedingungen in der Frühphase der Optimierung zu arbeiten, sie nannten dies einen Genetischen Algorithmus mit α‑Randbedingungen [3]. Die Methode wurde später vereinfacht und als Optimierung mit ε‑Randbedingungen bezeichnet. Sie kann für verschiedene Optimierungsalgorithmen verwendet werden, nicht nur für Genetische Algorithmen und Varianten [4]. Von speziellem Interesse in diesem Zusammenhang ist die Anwendung der Methode für Differential Evolution [5], [6], aber natürlich kann sie auch für andere Formen von Genetischen Algorithmen verwendet werden.

Das ε im Namen dieser Methode kann für das δ bei der Konversion von Gleichheits-Bedingungen in Ungleich-Bedingungen verwendet werden, ist aber nicht auf diesen Anwendungsfall eingeschränkt.

Während des Optimimierungslaufes wird in jeder Generation ein neuer Wert für ε berechnet. Der Vergleich von Individuen wie oben skizziert wird so modifiziert, dass ein Individuum so behandelt wird als verletze es keine Randbedingung wenn die Verletzung der Randbedingungen kleiner ist als ε. Wenn also beide Individuen die Randbedingungen um mehr als ε verletzen gewinnt das Individuum mit der kleineren Verletzung. Wenn ein Individuum die Bedingungen um weniger als ε verletzt, das andere aber mehr, gewinnt das erste. Und schließlich wenn die Verletzung der Randbedingungen bei beiden Individuen kleiner ist als ε findet der normale Zielfunktions-Vergleich statt.

Dieser letzte Fall ist der Schlüssel zum Erfolg des Algorithmus: Obwohl die Suche in eine Richtung zu weniger Verletzung der Randbedingungen fortschreitet, wird gleichzeitig eine gute Lösung bezogen auf die Zielfunktionenen gefunden.

Der Algorithmus startet mit der Initialisierung von \(\varepsilon_0\) mit der Summe der Verletzung der Randbedingungen des Individuums mit dem Index \(\theta\) der nach Verletzung der Randbedingungen sortierten initialen Population. Dabei ist \(\theta\) ein Parameter des Algorithmus zwischen 1 und der Populationsgröße, ein guter Wert ist etwa 20% der Populationsgröße, was auch der Standardwert in PGAPack ist. In jeder Generation \(t\) wird \(\varepsilon_t\) berechnet zu:

\begin{equation*} \varepsilon_t = \varepsilon_0 \left(1-\frac{t}{T_c}\right)^{cp} \end{equation*}

bis zur Generation \(T_c\). Nach dieser Generation wird ε auf 0 gesetzt. Der Exponent \(cp\) ist zwischen 2 und 10. Der Artikel von 2010 [6] empfiehlt zur Generation \(T_\lambda = 0.95 T_c\) den Wert \(cp = 0.3 cp + 0.7 cp_\min\) zu setzen, wobei \(cp_\min\) der fixe Wert 3 ist. Der Anfangswert von \(cp\) wird so gewählt dass zur Generation \(T_\lambda\) \(\varepsilon_\lambda=10^{-5}\) ist ausser \(cp\) wäre kleiner, dann wird es auf \(cp_\min\) gesetzt. PGAPack implementiert diese Empfehlung für \(cp\) als Standard-Einstellung, erlaubt aber das Setzen von \(cp\) zu Beginn und während der Laufzeit der Optimierung. Es ist also einfach möglich, andere Änderungen von \(cp\) zu implementieren – die Standard-Einstellung funktioniert jedoch recht gut.

Durch Optimierung mit ε Randbedingungen konnten in meinen Experimenten die Gleichheits-Bedingungen in Beispiel 7 von Deb [1] mit einer Genauigkeit von \(10^{-6}\) approximiert werden, siehe die Variable epsilon_generation im optimizer Beispiel.

Wird der antenna-optimizer mit ε‑generation 50 gestartet (das ist der \(T_c\) aus obigem Algorithmus) bleibt der Algorithmus nur in einem einzigen Fall im lokalen Optimum stecken, alle anderen Fälle finden gute Lösungen:

In dieser Grafik sind alle Lösungen von einem Optimierungslauf die von den Lösungen eines anderen Laufs dominiert werden in Schwarz eingezeichnet. Wir sehen dass die Daten von Lauf 16 keine nicht-dominierte Lösung beigetragen haben (auf der rechten Seite in der Legende fehlt die Nummer 16). In der Grafik kann man die dominierten Lösungen aus der Anzeige nehmen indem man auf den schwarzen Kreis in der Legende klickt.

Wenn der Parameter ε‑generation für den Lauf der im lokalen Optimum geendet hat auf 60 erhöht wird, findet auch der Lauf mit Zufallszahlen-Startwert 16 eine Lösung:

Es ist auch sichtbar dass die Lösungen für alle Experimente ziemlich gut (weil nahe an der Pareto-Front) sind. Der schwarze "Schatten" der dominierten Lösungen ist ziemlich nahe an der wirklichen Pareto-Front und ist gut genug um mit einem einzigen Optimierungslauf ausreichend gute Lösungen zu finden.