Testdaten Generierung mit LLMs

Testdaten werden in der Softwareentwicklung benötigt, um Systeme, Algorithmen oder Datenbanken unter realistischen Bedingungen zu evaluieren und ihre Robustheit zu gewährleisten. Dabei geht es darum, Fehler frühzeitig zu erkennen und die Leistungsfähigkeit zu optimieren. Und dies am besten vor einem Einsatz in der realen Welt. Besonders in Bereichen wie maschinellem Lernen, wo große Datenmengen erforderlich sind, sind hochwertige und vielfältige Testdaten unverzichtbar. Oftmals ist es jedoch schwierig, ausreichend reale Daten zu beschaffen, was den Bedarf an synthetischen Testdaten erhöht.

Testdaten erstellen leicht gemacht, oder nicht?

Large Language Models (LLMs) bieten hier eine innovative Lösung. Sie können nicht nur natürlich klingende Texte generieren, sondern auch in der Lage sein, strukturierte Daten zu erzeugen, die spezifische Anforderungen erfüllen. Dadurch lassen sich Testdaten schneller, einfacher und vielfältiger erstellen. Ich möchte meine Erfahrungen mit Gemini 1.5 und GPT-4o teilen. 

Stand der Technik

Einführung structured output

Seit August 2024  bieten gängige LLMs wie Gemini 1.5 und GPT-4o die Möglichkeit nicht nur strukturierte Ausgaben, wie JSON zu erstellen, sondern auch ein entsprechendes Schema mitzugeben, wie diese Daten aussehen sollen.

Bereits vorher war es möglich JSON zu generieren, aber es war nicht sicher, dass das Ergebnis auch “nur” JSON und valide ist. Ganz zu schweigen davon, dass es auch genau die Form haben muss, in der man es weiterverarbeiten möchte.

Gemini 1.5

Im Google Kontext kann man die Steuerung des Outputs durch die GenerationConfig steuern.

Mehr dazu findet man hier.

				
					import vertexai
from vertexai.generative_models import GenerativeModel
from vertexai.generative_models._generative_models import GenerationConfig

vertexai.init(project="your-project", location="europe-west1")
    model = GenerativeModel("gemini-1.5-flash-001",
        system_instruction=[system_prompt])
    generation_config = GenerationConfig(
        **{
            "max_output_tokens": 8192,
            "temperature": 1,
            "top_p": 0.95,
            "response_mime_type": "application/json",
            "response_schema": response_schema,
        }
    )
    responses = model.generate_content(
        [user_message],
        generation_config=generation_config,
    )
				
			

In diesem konkreten Beispiel versuchen wir Übereinstimmungen in der Auflistung jeweils zweier Schadenslisten zu finden.

				
					  response_schema={
    "type": "array",
    "items": {
        "type": "object",
        "properties": {
            "defect_part_matching_rating": {"type": "number"},
            "damage_type_matching_rating": {"type": "number"},
        },
        "required": [
            "defect_part_matching_rating",
            "damage_type_matching_rating",
        ],
    },
},
				
			

GPT-4o

Auch Open AI bietet die Ausgabe im JSON-Format an. Bisher ähnlich wie bei Google nur über den Prompt und den erwarteten Output-Typ. 
Aber auch hier ist es nun möglich ein response_format anzugeben.

				
					POST /v1/chat/completions
{
  "model": "gpt-4o-2024-08-06",
  "messages": [
    {
      "role": "system",
      "content": "You are a helpful math tutor."
    },
    {
      "role": "user",
      "content": "solve 8x + 31 = 2"
    }
  ],
  "response_format": {
    "type": "json_schema",
    "json_schema": {
      "name": "math_response",
      "strict": true,
      "schema": {
        "type": "object",
        "properties": {
          "steps": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "explanation": {
                  "type": "string"
                },
                "output": {
                  "type": "string"
                }
              },
              "required": ["explanation", "output"],
              "additionalProperties": false
            }
          },
          "final_answer": {
            "type": "string"
          }
        },
        "required": ["steps", "final_answer"],
        "additionalProperties": false
      }
    }
  }
}
				
			

Ergebnis

Die Generierung ist bei Gemini 1.5 und GPT-4o vergleichbar gut. Das Format ist in beiden Fällen durch das Forcieren fest und führt somit nicht zu Fehlern.

Leider kommt man mit einem einfachen Prompt und komplexen Anforderungen an die Testdaten schnell an die Grenzen der aktuellen Modelle.

Betrachten wir beispielsweise die erzeugten Testdaten, findet man schnell invalide Werte:

				
					{'user_name': 'Felix Klein',
   'postal_code': '90402',
   'brand': 'Mazda',
   'model': 'CX-30',
   'year_of_manifacture': '2018-2023',
   'license_plate': 'N-FK 450000000',
   'retention': '0'}
				
			

Das Nummernschild ist hier immer weiter mit Nullen aufgefüllt worden. Der Name taucht beispielsweise bei 100 Daten manchmal 30-mal auf, als hätte eine Person 30 Autos. Auch die Postleitzahl ist in 100 Datensätzen nicht direkt sinnvoll verteilt, sondern besteht aus 3 Werten, die sich wiederholen (z.B. 90402, 90403, 90404). Welche Möglichkeiten gibt es, um dem entgegenzuwirken?

Verbesserungsmöglichkeiten

1. Das Offensichtliche ist, den Prompt anzupassen und mehr zu beschreiben.

2. Beim GPT-Modell gibt es zusätzlich die description im JSON-Response-Format, mit der man die generierten Daten näher Beschreiben kann.

				
					 "license_plate": {
        "type": "string",
        "description": "The license plate of the car. i.e. F-CT 428 where F means Frankfurt and is mostly a region that is also represented by the postal_code. Followed by two random letters that sometimes is an akronym of the UserNames (e.g. Max Mustermensch -> MM)). The last part is a number between 100 and 2999 and is equally distributed for the german population.",
    },
				
			

3. Eine weitere Möglichkeit ist die Verwendung des Enum-Types, sofern passend.

				
					 "damage_type": {
        "type": "string",
        "enum": [
            "Riss",
            "Delle",
            "Glasbruch",
            "Lampe kaputt",
            "Kratzer",
            "Reifen platt",
        ],
    },
				
			

4. Um aber merkliche Verbesserungen zu erzielen, empfehle ich Few-Shot-Learning zu nutzen und mit Beispielen eine Richtung mitzugeben. So könnte man das Prompt zum Überprüfen der Übereinstimmung zwischen zwei JSON-Listen an Schäden mit Gemini 1.5 wie folgt aufbauen:

				
					Bewerte auf einer Skala von 0.0 bis 1.0, wie ähnlich sich die Art des Schadens und die defekten Teile der beiden Eingaben sind?
    0 bedeutet, dass die Values der beiden JSON vom Sinn her komplett unterschiedlich sind. 1 bedeutet, dass die JSON vom Sinn gleich sind.
    Betrachte jeden Eintrag der ersten Eingabe der Reihe nach mit allen Eingaben der zweiten Eingabe und berechne, wie ähnlich der passendste Eintrag der zweiten Eingabe ist.

    
        
            Bitte vergleiche NUR die zwei folgenden JSON miteinander:
            [{{'damage_type': 'Delle', 'damage_location':'Stoßstange'}}]
            [{{'damage_type': 'Beule', 'damage_location':'Vorne am Auto'}}]
        
        
            [{{'damage_type': 1.0, 'damage_location':0.9}}]
        
        
            Der Schaden 'Delle' ist synonym zu 'Beule' und das Teil 'Stoßstange' relativ ähnlich zu 'Vorne am Auto'.
        
    
    
        
            Bitte vergleiche NUR die zwei folgenden JSON miteinander:
            [{{'damage_type': 'Delle', 'damage_location':'Stoßstange'}}]
            [{{'damage_type': 'Kratzer', 'damage_location':'Rechte Tür'}}]
        
        
            [{{'damage_type': 0.0, 'damage_location':0.0}}]
    
    
            Der Schaden 'Delle' ist tritt zwar häufig in Verbindung mit 'Kratzer' auf ist jedoch in Verbindung mit den Bauteilen nicht plausibel.
            Das Teil 'Stoßstange' ist vorne am Auto und die rechte Tür an der Seite. Die Bauteile unterscheiden sich von Ort und Art vollständig'.
        
    
    
        
            Bitte vergleiche NUR die zwei folgenden JSON miteinander:
            [{{'damage_type': 'Glasschaden', 'damage_location':'Windschutzscheibe'}}]
            [{{'damage_type': 'Kratzer', 'damage_location':'Stoßstange'}}]
        
        
            [{{'damage_type': 0.0, 'damage_location':0.1}}]
        
        
            Der Schaden 'Glasschaden' und 'Kratzer' sind komplett unterschiedlich.
            Die Teile 'Stoßstange' und 'Windschutzscheibe' sind beide vorne am Auto, jedoch ist noch die Motorhaube dazwischen, welche nicht genannt ist.
            Die Bauteile unterscheiden sich stark von Ort und vollständig von der Art'.
        
    
    
        
            Bitte vergleiche NUR die zwei folgenden JSON miteinander:
            [{{'damage_type': 'Glasschaden', 'damage_location':'Windschutzscheibe'}},{{'damage_type': 'Delle', 'damage_location':'Stoßstange'}}]
            [{{'damage_type': 'Kratzer', 'damage_location':'Stoßstange'}}]
        
        
            [{{'damage_type': 0.0, 'damage_location':0.1}}, {{'damage_type': 0.5, 'damage_location':1.0}}]
        
        
            Der Schaden 'Glasschaden' in Verbindung mit 'Windschutzscheibe' ist komplett unterschiedlich zu 'Kratzer' und 'Stoßstange', nur der Ort ist leicht übereinstimmend.
            Der Schaden 'Delle' in Verbindung mit 'Stoßstange' ist jedoch ähnlich zu 'Kratzer' und 'Stoßstange', wobei hier das Bauteil übereinstimmt, nur der Typ eine gewisse Verwandtschaft zeigt.
        
    
    
        
            Bitte vergleiche NUR die zwei folgenden JSON miteinander:
            [{{'damage_type': 'Glasschaden', 'damage_location':'Windschutzscheibe'}},{{'damage_type': 'Kratzer', 'damage_location':'Stoßstange'}}]
            [{{'damage_type': 'Kratzer', 'damage_location':'Stoßstange'}}]
        
        
            [{{'damage_type': 0.0, 'damage_location':0.1}}, {{'damage_type': 1.0, 'damage_location':1.0}}]
        
        
            Der Schaden 'Glasschaden' in Verbindung mit 'Windschutzscheibe' ist komplett unterschiedlich zu 'Kratzer' und 'Stoßstange', nur der Ort ist leicht übereinstimmend.
            Der Schaden 'Kratzer' in Verbindung mit 'Stoßstange' ist identisch zu 'Kratzer' und 'Stoßstange'.
    
    
        
            Bitte vergleiche NUR die zwei folgenden JSON miteinander:
            [{{'damage_type': 'Glasbruch', 'defect_part': 'Windschutzscheibe'}}, {{'damage_type': 'Riss', 'defect_part': 'Stoßstange'}}, {{'damage_type': 'Riss', 'defect_part': 'Kotflügel'}}, {{'damage_type': 'Riss', 'defect_part': 'Scheinwerfer'}}]
            [{{'damage_type': 'Riss', 'defect_part': 'Frontstoßstange'}}, {{'damage_type': 'Glasbruch', 'defect_part': 'Windschutzscheibe'}}]
        
        
            [{{'damage_type': '1.0', 'defect_part': '1.0'}}, {{'damage_type': '1.0', 'defect_part': '0.9'}}, {{'damage_type': '0.3', 'defect_part': '0.3'}}, {{'damage_type': '0.3', 'defect_part': '0.3'}}]
        
        
            Der Schaden 'Glasbruch' in Verbindung mit 'Windschutzscheibe' ist identisch zu 'Glasbruch' und 'Windschutzscheibe'.
            Der Schaden 'Riss' in Verbindung mit 'Stoßstange' ist identisch zu 'Riss' und 'Frontstoßstange', wobei die Stoßstange nicht ganz so spezifisch wie Frontstoßstange ist.
            Der Schaden 'Riss' in Verbindung mit 'Kotflügel' ist ähnlich zu 'Riss' und 'Frontstoßstange', da nah beieinander sind und häufig zusammen auftreten können.
            Der Schaden 'Riss' in Verbindung mit 'Scheinwerfer' ist ähnlich zu 'Riss' und 'Frontstoßstange', da nah beieinander sind und häufig zusammen auftreten können.
        
    
    
        
            Bitte vergleiche NUR die zwei folgenden JSON miteinander:
            [{{'damage_type': 'Glasschaden', 'damage_location':'Windschutzscheibe'}}]
            [{{'damage_type': 'Kaputt', 'damage_location':'Frontscheibe'}}]
        
        
            [{{'damage_type': 0.8, 'damage_location':1.0}}]
        
        
            Der Schaden 'Glasschaden' ist ähnlich zu 'Kaputt', wobei kaputt einfach sehr generell ist und einen Glasschaden nicht ausschließt.
            'Windschutzscheibe' ist identisch zu 'Frontscheibe'.
        
    


    Bitte vergleiche NUR die zwei folgenden JSON miteinander:
    {}\n
    {}"""
				
			

5. Eine Alternative ist die Komplexität des Problems zu verringern. Das kann bei der Generierung von Testdaten erreicht werden, indem die Spalten der Daten schrittweise generiert werden. D.h. zuerst 50 User, dann beispielsweise 100 Autos und diese mit der gewünschten Wahrscheinlichkeit den Usern zuweisen.

Fazit

Für einfache Generierungen sind LLMs sehr gut. Sobald es jedoch komplexer wird, muss man sehr gut aufpassen, dass auch das erzeugt wird, was man sich vorstellt. Um die Ausgabe zu verbessern, gibt es einige Möglichkeiten, jedoch wird es immer aufwendiger, wenn man alles explizit angeben muss.
Da es zurzeit jedoch keine bessere Möglichkeit gibt unstrukturierte Daten zu generieren, würde ich LLMs dennoch immer empfehlen (zumindest für Teilschritte). Doch es gilt wie immer:

LLMs sind sehr hilfreich, jedoch sollte man die Ausgaben stets auf Korrektheit überprüfen.

Und wir dürfen natürlich gespannt sein, wie sich die Modelle weiterentwickeln. Ich kann mir gut vorstellen, dass diese Aufgabe in einem Jahr schon zufriedenstellend gelöst werden kann.

Mehr zum Thema KI

Weitere KI-Lösungen von compeople und Blogposts zum Thema.