freightiq / scripts /train_reranker.py
yyouretoast's picture
refactor: standardize production project layout, data paths, and scripts
3e151a2
Raw
History Blame Contribute Delete
10.4 kB
import sys
import os
# Ensure project root is in Python path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import json
import random
import logging
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader, TensorDataset
import config
from rag.reranker import CarrierReRanker, get_embed_model
from rag.utils import format_carrier_document
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s")
logger = logging.getLogger(__name__)
def generate_bootstrap_data(carriers):
"""
Generate synthetic query-document pairs directly from carrier attributes
to bootstrap model training in the absence of sufficient user feedback.
"""
logger.info("Generating bootstrap query-document pairs...")
bootstrap_pairs = []
# Define query templates
for c in carriers:
doc = format_carrier_document(c)
# 1. State queries
q_state = f"Find a carrier headquartered in {c['hq_state']}"
bootstrap_pairs.append((q_state, doc, 1.0))
# Negative state match
other_states = [other for other in carriers if other['hq_state'] != c['hq_state']]
if other_states:
neg_c = random.choice(other_states)
bootstrap_pairs.append((q_state, format_carrier_document(neg_c), 0.0))
# 2. Equipment queries
for eq in c['equipment_types']:
q_eq = f"We need a carrier with {eq} equipment"
bootstrap_pairs.append((q_eq, doc, 1.0))
# Negative equipment match
other_eqs = [other for other in carriers if eq not in other['equipment_types']]
if other_eqs:
neg_c = random.choice(other_eqs)
bootstrap_pairs.append((q_eq, format_carrier_document(neg_c), 0.0))
# 3. Specialization queries
for spec in c['cargo_specializations']:
q_spec = f"Show me carriers that specialize in {spec}"
bootstrap_pairs.append((q_spec, doc, 1.0))
# Negative spec match
other_specs = [other for other in carriers if spec not in other['cargo_specializations']]
if other_specs:
neg_c = random.choice(other_specs)
bootstrap_pairs.append((q_spec, format_carrier_document(neg_c), 0.0))
# 4. Safety rating queries
q_safety = f"Find a carrier with a {c['safety_rating']} safety rating"
bootstrap_pairs.append((q_safety, doc, 1.0))
# Negative safety match
other_safeties = [other for other in carriers if other['safety_rating'] != c['safety_rating']]
if other_safeties:
neg_c = random.choice(other_safeties)
bootstrap_pairs.append((q_safety, format_carrier_document(neg_c), 0.0))
return bootstrap_pairs
def load_feedback_data(carriers):
"""
Load user feedback from feedback.json and parse it into query-document pairs.
"""
feedback_path = config.FEEDBACK_PATH
if not os.path.exists(feedback_path):
logger.info("No feedback.json file found.")
return []
try:
with open(feedback_path, "r", encoding="utf-8") as f:
data = json.load(f)
except Exception as e:
logger.error(f"Failed to read feedback.json: {e}")
return []
feedback_pairs = []
logger.info(f"Parsing {len(data)} feedback records...")
for record in data:
query = record.get("query", "")
response = record.get("response", "")
feedback = record.get("feedback", "")
if not query or not response or not feedback:
continue
# Determine label based on thumbs up/down
label = 1.0 if feedback == "up" else 0.0
# Identify which carriers were mentioned in the response
matched_carriers = []
for c in carriers:
if c["carrier_name"] in response or c["dot_number"] in response:
matched_carriers.append(c)
for mc in matched_carriers:
doc = format_carrier_document(mc)
feedback_pairs.append((query, doc, label))
if label == 1.0:
unmatched = [other for other in carriers if other not in matched_carriers]
if unmatched:
neg_c = random.choice(unmatched)
feedback_pairs.append((query, format_carrier_document(neg_c), 0.0))
logger.info(f"Extracted {len(feedback_pairs)} query-document pairs from feedback logs.")
return feedback_pairs
def main():
logger.info("=== Starting PyTorch CarrierReRanker Training Pipeline ===")
# Load carrier database
json_path = config.CARRIERS_JSON_PATH
if not os.path.exists(json_path):
raise FileNotFoundError(f"Source carriers.json not found at {json_path}. Run setup.py first.")
with open(json_path, "r", encoding="utf-8") as f:
carriers = json.load(f)
# Load feedback and bootstrap dataset
real_pairs = load_feedback_data(carriers)
bootstrap_pairs = generate_bootstrap_data(carriers)
# Combine real feedback pairs and bootstrap synthetic pairs
all_pairs = real_pairs + bootstrap_pairs
logger.info(f"Total dataset size: {len(all_pairs)} query-document pairs.")
# Deduplicate and split queries / docs
queries = [p[0] for p in all_pairs]
documents = [p[1] for p in all_pairs]
labels = [p[2] for p in all_pairs]
# Load SentenceTransformer model to generate embeddings
embed_model = get_embed_model()
logger.info("Generating query embeddings...")
query_embs = embed_model.encode(queries, show_progress_bar=True, convert_to_numpy=True)
logger.info("Generating document embeddings...")
doc_embs = embed_model.encode(documents, show_progress_bar=True, convert_to_numpy=True)
# Convert to PyTorch tensors
X_query = torch.tensor(query_embs, dtype=torch.float32)
X_doc = torch.tensor(doc_embs, dtype=torch.float32)
y = torch.tensor(labels, dtype=torch.float32).unsqueeze(1)
# Split into train/validation datasets (80% train, 20% validation)
dataset_size = len(all_pairs)
indices = list(range(dataset_size))
random.seed(42) # For reproducible splits
random.shuffle(indices)
split_idx = int(0.8 * dataset_size)
train_indices = indices[:split_idx]
val_indices = indices[split_idx:]
X_query_train, X_query_val = X_query[train_indices], X_query[val_indices]
X_doc_train, X_doc_val = X_doc[train_indices], X_doc[val_indices]
y_train, y_val = y[train_indices], y[val_indices]
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
logger.info(f"Training on device: {device} | Train Size: {len(train_indices)} | Val Size: {len(val_indices)}")
train_dataset = TensorDataset(X_query_train, X_doc_train, y_train)
val_dataset = TensorDataset(X_query_val, X_doc_val, y_val)
train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True)
val_loader = DataLoader(val_dataset, batch_size=64, shuffle=False)
# Initialize ReRanker model
model = CarrierReRanker(embedding_dim=config.EMBEDDING_DIM, hidden_dim=config.RERANKER_HIDDEN_DIM).to(device)
criterion = nn.BCEWithLogitsLoss()
optimizer = optim.Adam(model.parameters(), lr=0.001)
# Train model with early stopping
epochs = 15
patience = 5
best_val_loss = float('inf')
epochs_without_improvement = 0
best_model_state = None
logger.info(f"Training MLP model for up to {epochs} epochs (early stopping patience={patience})...")
for epoch in range(epochs):
# 1. Training Phase
model.train()
epoch_train_loss = 0.0
for batch_query, batch_doc, batch_label in train_loader:
batch_query = batch_query.to(device)
batch_doc = batch_doc.to(device)
batch_label = batch_label.to(device)
optimizer.zero_grad()
predictions = model(batch_query, batch_doc)
loss = criterion(predictions, batch_label)
loss.backward()
optimizer.step()
epoch_train_loss += loss.item() * batch_query.size(0)
avg_train_loss = epoch_train_loss / len(train_dataset)
# 2. Validation Phase
model.eval()
epoch_val_loss = 0.0
with torch.no_grad():
for batch_query, batch_doc, batch_label in val_loader:
batch_query = batch_query.to(device)
batch_doc = batch_doc.to(device)
batch_label = batch_label.to(device)
predictions = model(batch_query, batch_doc)
loss = criterion(predictions, batch_label)
epoch_val_loss += loss.item() * batch_query.size(0)
avg_val_loss = epoch_val_loss / len(val_dataset)
if (epoch + 1) % 5 == 0 or epoch == 0:
logger.info(f"Epoch {epoch+1:02d}/{epochs:02d} | Train Loss: {avg_train_loss:.5f} | Val Loss: {avg_val_loss:.5f}")
# Early stopping: save best model and track improvement
if avg_val_loss < best_val_loss:
best_val_loss = avg_val_loss
best_model_state = model.state_dict().copy()
epochs_without_improvement = 0
logger.info(f" ↳ New best val loss: {best_val_loss:.5f} — checkpoint saved.")
else:
epochs_without_improvement += 1
if epochs_without_improvement >= patience:
logger.info(f"Early stopping triggered at epoch {epoch+1} (no improvement for {patience} epochs).")
break
# Save the best model weights
if best_model_state is None:
best_model_state = model.state_dict()
weights_path = config.WEIGHTS_PATH
os.makedirs(os.path.dirname(weights_path), exist_ok=True)
torch.save(best_model_state, weights_path)
logger.info(f"Best model weights (val loss={best_val_loss:.5f}) saved to {weights_path}")
logger.info("=== Reranker Training Pipeline Complete ===")
if __name__ == "__main__":
main()