Tymec's picture
Move common logic to function
d8a44ef
raw
history blame
No virus
8.82 kB
"""CLI using Click."""
from __future__ import annotations
import os
import sys
from pathlib import Path
from typing import Literal
import click
import joblib
import pandas as pd
from app.constants import TOKENIZER_CACHE_DIR
__all__ = ["cli_wrapper"]
DONE_STR = click.style("DONE", fg="green")
def _load_dataset(
dataset: str,
batch_size: int = 512,
n_jobs: int = 4,
force_cache: bool = False,
) -> tuple[pd.Series, pd.Series]:
"""Helper function to load and tokenize the dataset or use cached data if available.
Args:
dataset: Name of the dataset
batch_size: Batch size for tokenization
n_jobs: Number of parallel jobs
force_cache: Whether to force using the cached data
Returns:
Tokenized text data and label data
"""
from app.data import load_data, tokenize
from app.utils import deserialize, serialize
token_cache_path = TOKENIZER_CACHE_DIR / f"{dataset}_tokenized.pkl"
label_cache_path = TOKENIZER_CACHE_DIR / f"{dataset}_labels.pkl"
use_cached_data = False
if token_cache_path.exists() and label_cache_path.exists():
use_cached_data = force_cache or click.confirm(
f"Found existing tokenized data for '{dataset}'. Use it?",
default=True,
)
if use_cached_data:
click.echo("Loading cached data... ", nl=False)
token_data = pd.Series(deserialize(token_cache_path))
label_data = joblib.load(label_cache_path)
click.echo(DONE_STR)
else:
click.echo("Loading dataset... ", nl=False)
text_data, label_data = load_data(dataset)
click.echo(DONE_STR)
click.echo("Tokenizing data... ")
token_data = tokenize(text_data, batch_size=batch_size, n_jobs=n_jobs, show_progress=True)
serialize(token_data, token_cache_path, show_progress=True)
joblib.dump(label_data, label_cache_path, compress=3)
click.echo("Dataset vocabulary size: ", nl=False)
vocab = token_data.explode().value_counts()
click.secho(str(len(vocab)), fg="blue")
return token_data, label_data
@click.group()
def cli() -> None: ...
@cli.command()
@click.option(
"--model",
"model_path",
required=True,
help="Path to the trained model",
type=click.Path(exists=True, file_okay=True, dir_okay=False, readable=True, resolve_path=True, path_type=Path),
)
@click.option(
"--share/--no-share",
default=False,
help="Whether to create a shareable link",
)
def gui(model_path: Path, share: bool) -> None:
"""Launch the Gradio GUI"""
from app.gui import launch_gui
os.environ["MODEL_PATH"] = model_path.as_posix()
launch_gui(share)
@cli.command()
@click.option(
"--model",
"model_path",
required=True,
help="Path to the trained model",
type=click.Path(exists=True, file_okay=True, dir_okay=False, readable=True, resolve_path=True, path_type=Path),
)
@click.argument("text", nargs=-1)
def predict(model_path: Path, text: list[str]) -> None:
"""Perform sentiment analysis on the provided text.
Note: Piped input takes precedence over the text argument
"""
from app.model import infer_model
# Combine the text arguments into a single string
text = " ".join(text).strip()
if not sys.stdin.isatty():
# If there is piped input, read it
piped_text = sys.stdin.read().strip()
text = piped_text or text
if not text:
msg = "No text provided"
raise click.UsageError(msg)
click.echo("Loading model... ", nl=False)
model = joblib.load(model_path)
click.echo(DONE_STR)
click.echo("Performing sentiment analysis... ", nl=False)
prediction = infer_model(model, [text])[0]
if prediction == 0:
sentiment = click.style("NEGATIVE", fg="red")
elif prediction == 1:
sentiment = click.style("POSITIVE", fg="green")
else:
sentiment = click.style("NEUTRAL", fg="yellow")
click.echo(sentiment)
@cli.command()
@click.option(
"--dataset",
default="test",
help="Dataset to evaluate the model on",
type=click.Choice(["test", "sentiment140", "amazonreviews", "imdb50k"]),
)
@click.option(
"--model",
"model_path",
required=True,
help="Path to the trained model",
type=click.Path(exists=True, file_okay=True, dir_okay=False, readable=True, resolve_path=True, path_type=Path),
)
@click.option(
"--cv",
default=5,
help="Number of cross-validation folds",
show_default=True,
type=click.IntRange(2, 50),
)
@click.option(
"--token-batch-size",
default=512,
help="Size of the batches used in tokenization",
show_default=True,
)
@click.option(
"--token-jobs",
default=4,
help="Number of parallel jobs to run for tokenization",
show_default=True,
)
@click.option(
"--eval-jobs",
default=1,
help="Number of parallel jobs to run for evaluation",
show_default=True,
)
@click.option(
"--force-cache",
is_flag=True,
help="Always use the cached tokenized data (if available)",
)
def evaluate(
dataset: Literal["test", "sentiment140", "amazonreviews", "imdb50k"],
model_path: Path,
cv: int,
token_batch_size: int,
token_jobs: int,
eval_jobs: int,
force_cache: bool,
) -> None:
"""Evaluate the model on the the specified dataset"""
from app.model import evaluate_model
token_data, label_data = _load_dataset(dataset, token_batch_size, token_jobs, force_cache)
click.echo("Loading model... ", nl=False)
model = joblib.load(model_path)
click.echo(DONE_STR)
click.echo("Evaluating model... ")
acc_mean, acc_std = evaluate_model(
model,
token_data,
label_data,
cv=cv,
n_jobs=eval_jobs,
)
click.secho(f"{acc_mean:.2%} ± {acc_std:.2%}", fg="blue")
@cli.command()
@click.option(
"--dataset",
required=True,
help="Dataset to train the model on",
type=click.Choice(["sentiment140", "amazonreviews", "imdb50k"]),
)
@click.option(
"--vectorizer",
default="tfidf",
help="Vectorizer to use",
type=click.Choice(["tfidf", "count", "hashing"]),
)
@click.option(
"--max-features",
default=20000,
help="Maximum number of features (should be greater than 2^15 when using hashing vectorizer)",
show_default=True,
type=click.IntRange(1, None),
)
@click.option(
"--min-df",
default=5,
help="Minimum document frequency for the features (ignored for hashing)",
show_default=True,
)
@click.option(
"--cv",
default=5,
help="Number of cross-validation folds",
show_default=True,
type=click.IntRange(2, 50),
)
@click.option(
"--token-batch-size",
default=512,
help="Size of the batches used in tokenization",
show_default=True,
)
@click.option(
"--token-jobs",
default=4,
help="Number of parallel jobs to run for tokenization",
show_default=True,
)
@click.option(
"--train-jobs",
default=1,
help="Number of parallel jobs to run for training",
show_default=True,
)
@click.option(
"--seed",
default=42,
help="Random seed (-1 for random seed)",
show_default=True,
type=click.IntRange(-1, None),
)
@click.option(
"--overwrite",
is_flag=True,
help="Overwrite the model file if it already exists",
)
@click.option(
"--force-cache",
is_flag=True,
help="Always use the cached tokenized data (if available)",
)
def train(
dataset: Literal["sentiment140", "amazonreviews", "imdb50k"],
vectorizer: Literal["tfidf", "count", "hashing"],
max_features: int,
min_df: int,
cv: int,
token_batch_size: int,
token_jobs: int,
train_jobs: int,
seed: int,
overwrite: bool,
force_cache: bool,
) -> None:
"""Train the model on the provided dataset"""
from app.constants import MODEL_DIR
from app.model import train_model
model_path = MODEL_DIR / f"{dataset}_{vectorizer}_ft{max_features}.pkl"
if model_path.exists() and not overwrite:
click.confirm(f"Model file '{model_path}' already exists. Overwrite?", abort=True)
token_data, label_data = _load_dataset(dataset, token_batch_size, token_jobs, force_cache)
click.echo("Training model... ")
model, accuracy = train_model(
token_data,
label_data,
vectorizer=vectorizer,
max_features=max_features,
min_df=min_df,
cv=cv,
n_jobs=train_jobs,
seed=seed,
)
click.echo("Model accuracy: ", nl=False)
click.secho(f"{accuracy:.2%}", fg="blue")
click.echo("Model saved to: ", nl=False)
joblib.dump(model, model_path, compress=3)
click.secho(str(model_path), fg="blue")
def cli_wrapper() -> None:
cli(max_content_width=120)
if __name__ == "__main__":
cli_wrapper()