File size: 10,057 Bytes
08a464b
 
 
 
 
 
 
 
0fa50e4
 
 
d4e77c6
 
08a464b
 
 
 
 
 
 
 
 
 
d4e77c6
 
 
 
 
0fa50e4
 
 
 
 
 
 
d4e77c6
 
 
eaf5dfe
 
 
 
 
 
0fa50e4
d4e77c6
eaf5dfe
 
 
 
 
 
 
 
 
d4e77c6
 
eaf5dfe
 
 
 
 
 
d4e77c6
 
08a464b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d4e77c6
 
 
 
 
 
 
 
 
 
eaf5dfe
 
 
d4e77c6
 
08a464b
d4e77c6
 
 
 
 
08a464b
eaf5dfe
08a464b
 
 
 
 
eaf5dfe
 
 
08a464b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24299eb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
876dd1e
24299eb
 
 
 
 
 
 
 
08a464b
 
 
 
 
 
 
 
 
 
eaf5dfe
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
"""LIBE: sentence-to-language-description similarity search."""
import csv
import logging
import os
import pickle
import re
from pathlib import Path

# Avoid CUDA-runtime probing in the parent process before ZeroGPU forks.
os.environ.setdefault('PYTORCH_NVML_BASED_CUDA_CHECK', '1')
from importlib.metadata import version
import spaces  # Import before torch / sentence_transformers for ZeroGPU.
import torch
import gradio as gr
from sentence_transformers import SentenceTransformer, util

ROOT = Path(__file__).resolve().parent
MODEL_ID = os.environ.get('MODEL_ID', '').strip()
MODEL_REVISION = os.environ.get('MODEL_REVISION') or None
MAX_CHARS = 5000
logging.basicConfig(level=logging.INFO)


def select_device(zero_gpu_flag, cuda_available):
    is_zero = zero_gpu_flag.strip().lower() in {'1', 'true', 'yes', 'on', 't'}
    return is_zero, 'cuda' if is_zero or cuda_available else 'cpu'


IS_ZERO_GPU = os.environ.get('SPACES_ZERO_GPU', '').strip().lower() in {
    '1', 'true', 'yes', 'on', 't'
}
# Short-circuit: never query CUDA availability in the ZeroGPU parent.
DEVICE = 'cuda' if IS_ZERO_GPU else ('cuda' if torch.cuda.is_available() else 'cpu')
logging.info('LIBE runtime: torch=%s cuda_build=%s spaces=%s gradio=%s',
             torch.__version__, torch.version.cuda, version('spaces'), version('gradio'))
logging.info('LIBE hardware: %s', 'ZeroGPU' if IS_ZERO_GPU else DEVICE)


def gpu_duration(text, cached_embeddings):
    return 120 if cached_embeddings is None else 30


@spaces.GPU(duration=gpu_duration)
def search_on_gpu(text, cached_embeddings):
    logging.info('LIBE GPU worker entered')
    with torch.inference_mode():
        if cached_embeddings is None:
            logging.info('Encoding language descriptions inside GPU worker')
            corpus = model.encode(
                descriptions, convert_to_tensor=True, normalize_embeddings=True,
                batch_size=16, show_progress_bar=True, device=DEVICE,
            )
        else:
            corpus = cached_embeddings.to(DEVICE)
        query = model.encode(
            [text], convert_to_tensor=True, normalize_embeddings=True,
            show_progress_bar=False, device=DEVICE,
        )
        hits = util.semantic_search(query, corpus, top_k=top_k)[0]
        # Return the initial embeddings to the parent process explicitly.
        # Worker-global cache mutations alone do not persist reliably on ZeroGPU.
        new_cache = corpus.cpu() if cached_embeddings is None else None
        return hits, new_cache


def load_descriptions(tsv_path, pickle_path):
    # Only load the maintainer's trusted pickle; never accept user-uploaded pickles.
    with open(pickle_path, 'rb') as f:
        selected_raw = pickle.load(f)['glotlid-corpus']
    selected = {str(lang).split('_')[0] for lang in selected_raw}
    descriptions = {}
    with open(tsv_path, encoding='utf-8') as f:
        for row in csv.reader(f, delimiter='\t'):
            if len(row) < 2 or row[0] not in selected:
                continue
            desc = row[1].replace('\xa0', ' ')
            desc = re.sub(r'\[\d+\]', '', desc)
            desc = re.sub(r'\s+', ' ', desc).strip()
            if desc:
                descriptions[row[0]] = desc
    missing = selected - descriptions.keys()
    logging.info('Matched %d languages; %d missing or empty descriptions.',
                 len(descriptions), len(missing))
    if missing:
        logging.warning('Missing examples: %s', sorted(missing)[:20])
    if not descriptions:
        raise ValueError('No languages matched. Check the TSV and glotlid-corpus IDs.')
    labels = sorted(descriptions)
    return labels, [descriptions[label] for label in labels]


if not MODEL_ID:
    raise ValueError('Set MODEL_ID in Space Settings → Variables to your trained model repository ID.')
labels, descriptions = load_descriptions(
    ROOT / 'data/WorldsLangs.tsv', ROOT / 'data/all_stats.pkl'
)
# Load the trained LIBE checkpoint, not the unmodified base LaBSE model.
model = SentenceTransformer(
    MODEL_ID, device='cpu' if IS_ZERO_GPU else DEVICE, revision=MODEL_REVISION,
    token=os.environ.get('HF_TOKEN') or None,
)
# No model inference in the parent process before the ZeroGPU worker starts.
corpus_embeddings = None
logging.info('LIBE startup: no description encoding; deferred to first request')
top_k = min(5, len(labels))


# Place model on CUDA outside the decorated function; ZeroGPU emulates this
# allocation until a request receives a real GPU. No GPU inference at startup.
model.to(DEVICE)

def create_demo():
    def identify(text):
        global corpus_embeddings
        text = (text or '').strip()
        if not text:
            raise gr.Error('Please enter some text.')
        if len(text) > MAX_CHARS:
            raise gr.Error(f'Please use at most {MAX_CHARS:,} characters.')
        hits, new_cache = search_on_gpu(text, corpus_embeddings)
        if new_cache is not None:
            corpus_embeddings = new_cache
        best = hits[0]
        i = best['corpus_id']
        rows = [
            [rank, labels[h['corpus_id']], round(float(h['score']), 4),
             descriptions[h['corpus_id']][:200]]
            for rank, h in enumerate(hits, 1)
        ]
        return labels[i], round(float(best['score']), 4), descriptions[i], rows

    demo = gr.Interface(
        fn=identify,
        inputs=gr.Textbox(label='Text to identify', lines=4,
                         placeholder='Type or paste a sentence…', max_length=MAX_CHARS),
        outputs=[
            gr.Textbox(label='Best-matching language', interactive=False),
            gr.Number(label='Cosine similarity — not a probability', precision=4),
            gr.Textbox(label='Language description', lines=5, interactive=False),
            gr.Dataframe(headers=['Rank', 'Language', 'Cosine similarity', 'Description excerpt'],
                         datatype=['number', 'str', 'number', 'str'],
                         label='Top candidates', interactive=False),
        ],
        title='LIBE · Language identification',
        description=(
            "**LIBE** identifies languages by comparing text embeddings with embeddings "
            "of natural-language descriptions. This demo uses a **LaBSE-based bi-encoder "
            "trained on 10 million sentences from GlotLID-C**. "
            f"It ranks {len(labels)} candidate languages by cosine similarity."
        ),
        article=(
            "## About LIBE\n\n"
            "Language identification is a key component of multilingual NLP pipelines and "
            "corpus construction. Identification errors can create spurious text–language "
            "associations and harm downstream model quality, particularly for low-resource "
            "languages and closely related languages or dialects.\n\n"
            "LIBE uses a bi-encoder initialized from **LaBSE** to embed input sentences and "
            "natural-language descriptions of languages into a shared semantic space. "
            "Language identification is then performed through similarity search. This "
            "formulation supports data-efficient learning and adding candidate languages "
            "through their descriptions. The checkpoint used here was trained on "
            "**10 million GlotLID-C sentences**; its performance should be distinguished "
            "from the paper's separate zero- and few-shot adaptation experiments.\n\n"
            "### Research findings\n\n"
            "Across multiple benchmarks, the paper reports competitive performance with "
            "strong LID systems, with substantially less task-specific supervision in "
            "several adaptation settings. It also introduces an optional **cross-encoder "
            "re-ranking stage** to improve discrimination in ambiguous or low-resource cases. "
            "The hybrid approach achieves up to **90% F1 with as few as five labeled examples** "
            "in the reported challenging open-set settings. This result concerns the hybrid "
            "experimental setup, not a performance guarantee for this demo. "
            "**This demo uses the bi-encoder only, without cross-encoder re-ranking.**\n\n"
            "### Interpreting predictions\n\n"
            "The top candidates are ranked by **cosine similarity, not calibrated confidence "
            "probabilities**. The demo always returns the closest candidate; it does not "
            "automatically reject unsupported languages. Short, mixed-language, or ambiguous "
            "texts may be difficult to identify. Adding a language description does not "
            "guarantee reliable identification without evaluation. "
            f"Long inputs may be truncated at the model's limit of {model.max_seq_length} tokens.\n\n"
            "### Paper and resources\n\n"
            "Rocco Tripodi (2026). **Representation Learning Enables Language Identification "
            "in Zero- and Few-Shot Settings.** *Transactions of the Association for "
            "Computational Linguistics*. To appear.\n\n"
            "[Code and data](https://github.com/cafoscari-nlp/LIBE) · "
            "[LIBE10M](https://huggingface.co/cafoscari-nlp/LIBE)\n\n"
            "```bibtex\n"
            "@article{tripodi-2026-libe,\n"
            "  author  = {Tripodi, Rocco},\n"
            "  title   = {Representation Learning Enables Language Identification in Zero- and Few-Shot Settings},\n"
            "  journal = {Transactions of the Association for Computational Linguistics},\n"
            "  year    = {2026},\n"
            "  note    = {To appear}\n"
            "}\n```"
        ),
        examples=[['Questa è una frase in italiano.'], ['This is a sentence in English.']],
        cache_examples=False,
        flagging_mode='never',
        submit_btn='Identify language',
    )
    return demo.queue(default_concurrency_limit=1, max_size=20)


if __name__ == '__main__':
    create_demo().launch(
        server_name='0.0.0.0', server_port=7860, prevent_thread_lock=False,
    )