CoolFace
Apppublic

Jenny0613/Datasets-in-machine-learning

sourceHugging Facemitupdated 8mo agoView on Hugging Face
0likes
app.py544 linesDownload Raw Back to root
1"""2FastAPI + HTMX app for browsing arxiv papers with new ML datasets.3Downloads Lance dataset from HuggingFace Hub and loads locally.4"""5 6import math7import re8from datetime import date, timedelta9from functools import lru_cache10from typing import Optional11from urllib.parse import urlencode12 13import lance14import polars as pl15from cachetools import TTLCache16from dotenv import load_dotenv17from fastapi import FastAPI, Query, Request18from fastapi.responses import HTMLResponse, RedirectResponse19from fastapi.staticfiles import StaticFiles20from fastapi.templating import Jinja2Templates21from huggingface_hub import snapshot_download22from markupsafe import Markup23 24# Load .env file for local development (HF_TOKEN)25load_dotenv()26 27app = FastAPI(title="ArXiv New ML Datasets")28app.mount("/static", StaticFiles(directory="static"), name="static")29templates = Jinja2Templates(directory="templates")30 31 32def highlight_search(text: str, search: str) -> Markup:33    """Highlight search terms in text with yellow background."""34    if not search or not text:35        return Markup(text) if text else Markup("")36 37    # Escape HTML in text first38    import html39    text = html.escape(str(text))40 41    # Case-insensitive replacement with highlight span42    pattern = re.compile(re.escape(search), re.IGNORECASE)43    highlighted = pattern.sub(44        lambda m: f'<mark class="bg-yellow-200 px-0.5 rounded">{m.group()}</mark>',45        text46    )47    return Markup(highlighted)48 49 50# Register custom filters51templates.env.filters["highlight"] = highlight_search52 53 54def confidence_fmt(score):55    """Format confidence as percentage, truncating to 1 decimal to avoid rounding 99.95->100."""56    pct = math.floor(score * 1000) / 1057    return f"{pct:.1f}"58 59 60templates.env.filters["confidence"] = confidence_fmt61 62# Dataset config63DATASET_REPO = "librarian-bots/arxiv-cs-papers-lance"64 65# Cache for dataset (reload every 6 hours)66_dataset_cache: TTLCache = TTLCache(maxsize=1, ttl=60 * 60 * 6)67 68# Cache for Lance dataset connection (for vector search)69_lance_cache: dict = {}70 71# Cache for embedding model (lazy loaded on first semantic search)72_model_cache: dict = {}73 74 75def get_lance_dataset():76    """Download dataset from HF Hub (cached) and return Lance connection."""77    if "ds" not in _lance_cache:78        import os79        # Use HF_HOME or /tmp for Spaces compatibility (./data not writable on Spaces)80        cache_base = os.environ.get("HF_HOME", "/tmp/hf_cache")81        local_dir = f"{cache_base}/arxiv-lance"82        print(f"Downloading dataset from {DATASET_REPO} to {local_dir}...")83        snapshot_download(84            DATASET_REPO,85            repo_type="dataset",86            local_dir=local_dir,87        )88        lance_path = f"{local_dir}/data/train.lance"89        print(f"Loading Lance dataset from {lance_path}")90        _lance_cache["ds"] = lance.dataset(lance_path)91    return _lance_cache["ds"]92 93 94def get_embedding_model():95    """Load embedding model (cached, lazy-loaded on first semantic search)."""96    if "model" not in _model_cache:97        from sentence_transformers import SentenceTransformer98        print("Loading embedding model...")99        _model_cache["model"] = SentenceTransformer("BAAI/bge-base-en-v1.5")100        print("Embedding model loaded!")101    return _model_cache["model"]102 103 104def get_dataframe() -> pl.DataFrame:105    """Load Lance dataset and convert to Polars DataFrame."""106    cache_key = "df"107    if cache_key in _dataset_cache:108        return _dataset_cache[cache_key]109 110    ds = get_lance_dataset()  # Downloads from HF Hub if not cached111    # Select columns needed for filtering/display (exclude embeddings for memory)112    columns = [113        "id", "title", "abstract", "categories", "update_date",114        "authors", "is_new_dataset", "confidence_score"115    ]116    arrow_table = ds.to_table(columns=columns)117    df = pl.from_arrow(arrow_table)118    _dataset_cache[cache_key] = df119    print(f"Loaded {len(df):,} papers")120    return df121 122 123@lru_cache(maxsize=1)124def get_categories() -> list[str]:125    """Get unique category prefixes for filtering."""126    df = get_dataframe()127    # Extract primary category (before first space or as-is)128    categories = (129        df.select(pl.col("categories").str.split(" ").list.first().alias("cat"))130        .unique()131        .sort("cat")132        .to_series()133        .to_list()134    )135    # Get common ML-related categories136    ml_cats = ["cs.AI", "cs.CL", "cs.CV", "cs.LG", "cs.NE", "cs.IR", "cs.RO", "stat.ML"]137    return [c for c in ml_cats if c in categories]138 139 140@lru_cache(maxsize=1)141def get_confidence_options() -> list[dict]:142    """Compute confidence filter options from actual data distribution.143 144    Uses percentiles so the UI adapts to any model's score range.145    """146    df = get_dataframe()147    scores = df.filter(pl.col("is_new_dataset"))["confidence_score"]148 149    options = [{"value": "0.5", "label": "All new datasets", "count": len(scores)}]150 151    for pct_label, quantile in [("Top 75%", 0.25), ("Top 50%", 0.50), ("Top 25%", 0.75)]:152        threshold = float(scores.quantile(quantile))153        count = scores.filter(scores >= threshold).len()154        options.append({155            "value": f"{threshold:.2f}",156            "label": pct_label,157            "count": int(count),158        })159 160    options.append({"value": "0", "label": "All papers", "count": len(df)})161    return options162 163 164@lru_cache(maxsize=1)165def get_histogram_data() -> dict:166    """Get confidence distribution data for histogram display.167 168    Dynamically determines the range from actual data distribution.169    Returns dict with bins and metadata. The 50% line marks the prediction boundary.170    """171    df = get_dataframe()172 173    # Get all papers with confidence scores174    all_papers = df.select("confidence_score", "is_new_dataset")175 176    # Dynamically determine the range from actual data177    # Round to nearest 5% for clean boundaries178    actual_min = float(all_papers["confidence_score"].min())179    actual_max = float(all_papers["confidence_score"].max())180 181    # Round down to nearest 5% for min, round up for max182    min_pct = max(0, (int(actual_min * 20) / 20))  # Floor to 5%183    max_pct = min(1, ((int(actual_max * 20) + 1) / 20))  # Ceil to 5%184 185    # Ensure minimum range of 25% for usability186    if max_pct - min_pct < 0.25:187        center = (min_pct + max_pct) / 2188        min_pct = max(0, center - 0.125)189        max_pct = min(1, center + 0.125)190 191    # Use 25 bins for good granularity192    num_bins = 25193    bin_width = (max_pct - min_pct) / num_bins194 195    bins = []196    for i in range(num_bins):197        bin_start = min_pct + i * bin_width198        bin_end = min_pct + (i + 1) * bin_width199 200        # Count papers in this bin201        count = all_papers.filter(202            (pl.col("confidence_score") >= bin_start) &203            (pl.col("confidence_score") < bin_end)204        ).height205 206        # Count new_dataset papers in this bin207        new_dataset_count = all_papers.filter(208            (pl.col("confidence_score") >= bin_start) &209            (pl.col("confidence_score") < bin_end) &210            (pl.col("is_new_dataset"))211        ).height212 213        bins.append({214            "bin_start": round(bin_start, 3),215            "bin_end": round(bin_end, 3),216            "bin_pct": int(bin_start * 100),217            "count": count,218            "new_dataset_count": new_dataset_count,219        })220 221    # Normalize counts for display (max height = 100%)222    max_count = max(b["count"] for b in bins) if bins else 1223    for b in bins:224        b["height_pct"] = int((b["count"] / max_count) * 100) if max_count > 0 else 0225        b["new_height_pct"] = int((b["new_dataset_count"] / max_count) * 100) if max_count > 0 else 0226 227    # Calculate cumulative counts from each threshold228    # (how many papers are at or above this threshold)229    total_so_far = all_papers.height230    for b in bins:231        b["papers_above"] = total_so_far232        total_so_far -= b["count"]233 234    return {235        "bins": bins,236        "min_pct": round(min_pct, 2),237        "max_pct": round(max_pct, 2),238        "total_papers": all_papers.height,239        "new_dataset_count": all_papers.filter(pl.col("is_new_dataset")).height,240    }241 242 243def parse_since(since: str) -> Optional[date]:244    """Parse 'since' parameter to a date. Returns None for 'all time'."""245    if not since:246        return None247    today = date.today()248    if since == "1m":249        return today - timedelta(days=30)250    elif since == "6m":251        return today - timedelta(days=180)252    elif since == "1y":253        return today - timedelta(days=365)254    return None255 256 257def filter_papers(258    df: pl.DataFrame,259    category: Optional[str] = None,260    search: Optional[str] = None,261    min_confidence: float = 0.5,262    since: Optional[str] = None,263) -> pl.DataFrame:264    """Apply filters to the papers dataframe.265 266    The confidence threshold controls which papers are shown:267    - Papers with is_new_dataset=True have confidence >= 0.5268    - Setting threshold to 0 shows all papers269    - Setting threshold >= 0.5 effectively shows only new_dataset papers270    """271    if min_confidence >= 0.5:272        # Show only papers classified as new datasets, filtered by confidence273        df = df.filter(274            pl.col("is_new_dataset") & (pl.col("confidence_score") >= min_confidence)275        )276    elif min_confidence > 0:277        df = df.filter(pl.col("confidence_score") >= min_confidence)278 279    if category:280        df = df.filter(pl.col("categories").str.contains(category))281 282    if search:283        search_lower = search.lower()284        df = df.filter(285            pl.col("title").str.to_lowercase().str.contains(search_lower)286            | pl.col("abstract").str.to_lowercase().str.contains(search_lower)287        )288 289    # Date filter290    min_date = parse_since(since)291    if min_date:292        df = df.filter(pl.col("update_date") >= min_date)293 294    return df295 296 297def paginate_papers(298    df: pl.DataFrame,299    page: int = 1,300    per_page: int = 20,301    sort: str = "date",302) -> tuple[pl.DataFrame, bool]:303    """Sort and paginate papers, return (page_df, has_more).304 305    Sort options:306    - "date": By update_date desc, then confidence_score desc307    - "relevance": Keep existing order (for semantic search similarity)308    """309    if sort == "date":310        df_sorted = df.sort(311            ["update_date", "confidence_score"], descending=[True, True]312        )313    else:314        # "relevance" - keep existing order (already sorted by similarity for semantic)315        df_sorted = df316 317    start = (page - 1) * per_page318    page_df = df_sorted.slice(start, per_page + 1)319    has_more = len(page_df) > per_page320 321    return page_df.head(per_page), has_more322 323 324def semantic_search(325    query: str,326    k: int = 100,327    category: Optional[str] = None,328    min_confidence: float = 0.5,329    since: Optional[str] = None,330) -> pl.DataFrame:331    """Search using vector similarity via Lance nearest neighbor.332 333    Returns DataFrame with similarity_score column (0-1, higher is more similar).334    """335    model = get_embedding_model()336    query_embedding = model.encode(query).tolist()337 338    ds = get_lance_dataset()339 340    # Build SQL filter (Lance supports SQL-like syntax)341    filters = []342    if min_confidence >= 0.5:343        filters.append("is_new_dataset = true")344        filters.append(f"confidence_score >= {min_confidence}")345    elif min_confidence > 0:346        filters.append(f"confidence_score >= {min_confidence}")347    if category:348        # Escape single quotes in category name for SQL safety349        safe_category = category.replace("'", "''")350        filters.append(f"categories LIKE '%{safe_category}%'")351    # Date filter - use TIMESTAMP literal for Lance/DataFusion352    min_date = parse_since(since)353    if min_date:354        filters.append(f"update_date >= TIMESTAMP '{min_date.isoformat()} 00:00:00'")355    filter_str = " AND ".join(filters) if filters else None356 357    # Vector search - include _distance for similarity calculation358    results = ds.scanner(359        nearest={"column": "embedding", "q": query_embedding, "k": k},360        filter=filter_str,361        columns=["id", "title", "abstract", "categories", "update_date",362                 "authors", "confidence_score", "_distance"]363    ).to_table()364 365    df = pl.from_arrow(results)366 367    # Convert L2 distance to similarity score (0-1 range)368    # For normalized embeddings: similarity = 1 - distance/2369    # BGE embeddings are normalized, so L2 distance ranges from 0 to 2370    df = df.with_columns(371        (1 - pl.col("_distance") / 2).clip(0, 1).alias("similarity_score")372    ).drop("_distance")373 374    return df375 376 377@app.get("/", response_class=HTMLResponse)378async def home(379    request: Request,380    search: Optional[str] = Query(None),381    search_type: str = Query("keyword"),382    category: Optional[str] = Query(None),383    min_confidence: str = Query("0.5"),  # String to preserve exact value for template384    since: Optional[str] = Query(None),385    sort: str = Query("date"),386):387    """Render the home page with optional initial filter state from URL."""388    df = get_dataframe()389    categories = get_categories()390    histogram_data = get_histogram_data()391    confidence_options = get_confidence_options()392 393    # Get stats394    total_papers = len(df)395    new_dataset_count = df.filter(pl.col("is_new_dataset")).height396 397    return templates.TemplateResponse(398        "index.html",399        {400            "request": request,401            "categories": categories,402            "total_papers": total_papers,403            "new_dataset_count": new_dataset_count,404            "histogram_data": histogram_data,405            "confidence_options": confidence_options,406            # Pass filter state for URL persistence407            "search": search or "",408            "search_type": search_type,409            "category": category or "",410            "min_confidence": min_confidence,411            "since": since or "",412            "sort": sort,413        },414    )415 416 417@app.get("/papers", response_class=HTMLResponse)418async def get_papers(419    request: Request,420    page: int = Query(1, ge=1),421    per_page: int = Query(20, ge=1, le=100),422    category: Optional[str] = Query(None),423    search: Optional[str] = Query(None),424    min_confidence: float = Query(0.5, ge=0, le=1),425    search_type: str = Query("keyword"),  # "keyword" or "semantic"426    sort: str = Query("date"),  # "date" or "relevance"427    since: Optional[str] = Query(None),  # "1m", "6m", "1y", or None for all428):429    """Get paginated and filtered papers (returns HTML partial for HTMX).430 431    If accessed directly (not via HTMX), redirects to home page with same params.432    """433    # Redirect direct browser visits to home page (this endpoint returns partials)434    if "HX-Request" not in request.headers:435        # Build redirect URL with current query params436        query_string = str(request.url.query)437        redirect_url = f"/?{query_string}" if query_string else "/"438        return RedirectResponse(url=redirect_url, status_code=302)439 440    if search and search_type == "semantic":441        # Vector search - returns pre-sorted by similarity442        filtered_df = semantic_search(443            query=search,444            k=per_page * 5,  # Get more for pagination buffer445            category=category,446            min_confidence=min_confidence,447            since=since,448        )449        # Default to relevance sort for semantic, but allow date sort450        effective_sort = sort if sort == "date" else "relevance"451        page_df, has_more = paginate_papers(452            filtered_df, page=page, per_page=per_page, sort=effective_sort453        )454    else:455        # Existing keyword search path456        df = get_dataframe()457        filtered_df = filter_papers(458            df,459            category=category,460            search=search,461            min_confidence=min_confidence,462            since=since,463        )464        # Keyword search always sorts by date465        page_df, has_more = paginate_papers(466            filtered_df, page=page, per_page=per_page, sort="date"467        )468 469    # Convert to list of dicts for template470    papers = page_df.to_dicts()471 472    # Build clean URL for browser history (/ instead of /papers)473    # Only include non-default values to keep URLs short474    params = {}475    if search:476        params["search"] = search477    if search_type != "keyword":478        params["search_type"] = search_type479    if category:480        params["category"] = category481    if min_confidence != 0.5:482        params["min_confidence"] = min_confidence483    if since:484        params["since"] = since485    if sort != "date":486        params["sort"] = sort487    push_url = "/?" + urlencode(params) if params else "/"488 489    response = templates.TemplateResponse(490        "partials/paper_list.html",491        {492            "request": request,493            "papers": papers,494            "page": page,495            "has_more": has_more,496            "category": category or "",497            "search": search or "",498            "min_confidence": min_confidence,499            "search_type": search_type,500            "sort": sort,501            "since": since or "",502            "total_filtered": len(filtered_df),503        },504    )505    # Tell HTMX to push clean URL (/ not /papers)506    response.headers["HX-Push-Url"] = push_url507    return response508 509 510@app.get("/api/stats")511async def get_stats():512    """Get dataset statistics as JSON."""513    df = get_dataframe()514 515    new_datasets = df.filter(pl.col("is_new_dataset"))516 517    return {518        "total_papers": len(df),519        "new_dataset_count": len(new_datasets),520        "avg_confidence": float(df["confidence_score"].mean()),521        "date_range": {522            "min": str(df["update_date"].min()),523            "max": str(df["update_date"].max()),524        },525    }526 527 528# Preload dataset and model on startup529@app.on_event("startup")530async def startup_event():531    """Preload dataset and embedding model on startup."""532    print("Preloading dataset...")533    get_dataframe()534    print("Dataset loaded!")535    print("Preloading embedding model...")536    get_embedding_model()537    print("Embedding model loaded!")538 539 540if __name__ == "__main__":541    import uvicorn542 543    uvicorn.run(app, host="0.0.0.0", port=7860)544