chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
# Cross-lingual Retrieval for Iterative Self-Supervised Training
|
||||
|
||||
https://arxiv.org/pdf/2006.09526.pdf
|
||||
|
||||
## Introduction
|
||||
|
||||
CRISS is a multilingual sequence-to-sequnce pretraining method where mining and training processes are applied iteratively, improving cross-lingual alignment and translation ability at the same time.
|
||||
|
||||
## Requirements:
|
||||
|
||||
* faiss: https://github.com/facebookresearch/faiss
|
||||
* mosesdecoder: https://github.com/moses-smt/mosesdecoder
|
||||
* flores: https://github.com/facebookresearch/flores
|
||||
* LASER: https://github.com/facebookresearch/LASER
|
||||
|
||||
## Unsupervised Machine Translation
|
||||
##### 1. Download and decompress CRISS checkpoints
|
||||
```
|
||||
cd examples/criss
|
||||
wget https://dl.fbaipublicfiles.com/criss/criss_3rd_checkpoints.tar.gz
|
||||
tar -xf criss_checkpoints.tar.gz
|
||||
```
|
||||
##### 2. Download and preprocess Flores test dataset
|
||||
Make sure to run all scripts from examples/criss directory
|
||||
```
|
||||
bash download_and_preprocess_flores_test.sh
|
||||
```
|
||||
|
||||
##### 3. Run Evaluation on Sinhala-English
|
||||
```
|
||||
bash unsupervised_mt/eval.sh
|
||||
```
|
||||
|
||||
## Sentence Retrieval
|
||||
##### 1. Download and preprocess Tatoeba dataset
|
||||
```
|
||||
bash download_and_preprocess_tatoeba.sh
|
||||
```
|
||||
|
||||
##### 2. Run Sentence Retrieval on Tatoeba Kazakh-English
|
||||
```
|
||||
bash sentence_retrieval/sentence_retrieval_tatoeba.sh
|
||||
```
|
||||
|
||||
## Mining
|
||||
##### 1. Install faiss
|
||||
Follow instructions on https://github.com/facebookresearch/faiss/blob/master/INSTALL.md
|
||||
##### 2. Mine pseudo-parallel data between Kazakh and English
|
||||
```
|
||||
bash mining/mine_example.sh
|
||||
```
|
||||
|
||||
## Citation
|
||||
```bibtex
|
||||
@article{tran2020cross,
|
||||
title={Cross-lingual retrieval for iterative self-supervised training},
|
||||
author={Tran, Chau and Tang, Yuqing and Li, Xian and Gu, Jiatao},
|
||||
journal={arXiv preprint arXiv:2006.09526},
|
||||
year={2020}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,64 @@
|
||||
#!/bin/bash
|
||||
# Copyright (c) Facebook, Inc. and its affiliates.
|
||||
# All rights reserved.
|
||||
#
|
||||
# This source code is licensed under the license found in the
|
||||
# LICENSE file in the root directory of this source tree.
|
||||
|
||||
SPM_ENCODE=flores/scripts/spm_encode.py
|
||||
DATA=data_tmp
|
||||
SPM_MODEL=criss_checkpoints/sentence.bpe.model
|
||||
DICT=criss_checkpoints/dict.txt
|
||||
|
||||
download_data() {
|
||||
CORPORA=$1
|
||||
URL=$2
|
||||
|
||||
if [ -f $CORPORA ]; then
|
||||
echo "$CORPORA already exists, skipping download"
|
||||
else
|
||||
echo "Downloading $URL"
|
||||
wget $URL -O $CORPORA --no-check-certificate || rm -f $CORPORA
|
||||
if [ -f $CORPORA ]; then
|
||||
echo "$URL successfully downloaded."
|
||||
else
|
||||
echo "$URL not successfully downloaded."
|
||||
rm -f $CORPORA
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
if [[ -f flores ]]; then
|
||||
echo "flores already cloned"
|
||||
else
|
||||
git clone https://github.com/facebookresearch/flores
|
||||
fi
|
||||
|
||||
mkdir -p $DATA
|
||||
download_data $DATA/wikipedia_en_ne_si_test_sets.tgz "https://github.com/facebookresearch/flores/raw/master/data/wikipedia_en_ne_si_test_sets.tgz"
|
||||
pushd $DATA
|
||||
pwd
|
||||
tar -vxf wikipedia_en_ne_si_test_sets.tgz
|
||||
popd
|
||||
|
||||
|
||||
for lang in ne_NP si_LK; do
|
||||
datadir=$DATA/${lang}-en_XX-flores
|
||||
rm -rf $datadir
|
||||
mkdir -p $datadir
|
||||
TEST_PREFIX=$DATA/wikipedia_en_ne_si_test_sets/wikipedia.test
|
||||
python $SPM_ENCODE \
|
||||
--model ${SPM_MODEL} \
|
||||
--output_format=piece \
|
||||
--inputs ${TEST_PREFIX}.${lang:0:2}-en.${lang:0:2} ${TEST_PREFIX}.${lang:0:2}-en.en \
|
||||
--outputs $datadir/test.bpe.${lang}-en_XX.${lang} $datadir/test.bpe.${lang}-en_XX.en_XX
|
||||
|
||||
# binarize data
|
||||
fairseq-preprocess \
|
||||
--source-lang ${lang} --target-lang en_XX \
|
||||
--testpref $datadir/test.bpe.${lang}-en_XX \
|
||||
--destdir $datadir \
|
||||
--srcdict ${DICT} \
|
||||
--joined-dictionary \
|
||||
--workers 4
|
||||
done
|
||||
@@ -0,0 +1,46 @@
|
||||
#!/bin/bash
|
||||
# Copyright (c) Facebook, Inc. and its affiliates.
|
||||
# All rights reserved.
|
||||
#
|
||||
# This source code is licensed under the license found in the
|
||||
# LICENSE file in the root directory of this source tree.
|
||||
|
||||
SPM_ENCODE=flores/scripts/spm_encode.py
|
||||
DATA=data_tmp
|
||||
SPM_MODEL=criss_checkpoints/sentence.bpe.model
|
||||
DICT=criss_checkpoints/dict.txt
|
||||
|
||||
if [[ -f flores ]]; then
|
||||
echo "flores already cloned"
|
||||
else
|
||||
git clone https://github.com/facebookresearch/flores
|
||||
fi
|
||||
if [[ -f LASER ]]; then
|
||||
echo "LASER already cloned"
|
||||
else
|
||||
git clone https://github.com/facebookresearch/LASER
|
||||
fi
|
||||
mkdir -p data_tmp
|
||||
declare -A lang_tatoeba_map=( ["ar_AR"]="ara" ["de_DE"]="deu" ["es_XX"]="spa" ["et_EE"]="est" ["fi_FI"]="fin" ["fr_XX"]="fra" ["hi_IN"]="hin" ["it_IT"]="ita" ["ja_XX"]="jpn" ["ko_KR"]="kor" ["kk_KZ"]="kaz" ["nl_XX"]="nld" ["ru_RU"]="rus" ["tr_TR"]="tur" ["vi_VN"]="vie" ["zh_CN"]="cmn")
|
||||
for lang in ar_AR de_DE es_XX et_EE fi_FI fr_XX hi_IN it_IT ja_XX kk_KZ ko_KR nl_XX ru_RU tr_TR vi_VN zh_CN; do
|
||||
lang_tatoeba=${lang_tatoeba_map[$lang]}
|
||||
echo $lang_tatoeba
|
||||
datadir=$DATA/${lang}-en_XX-tatoeba
|
||||
rm -rf $datadir
|
||||
mkdir -p $datadir
|
||||
TEST_PREFIX=LASER/data/tatoeba/v1/tatoeba
|
||||
python $SPM_ENCODE \
|
||||
--model ${SPM_MODEL} \
|
||||
--output_format=piece \
|
||||
--inputs ${TEST_PREFIX}.${lang_tatoeba}-eng.${lang_tatoeba} ${TEST_PREFIX}.${lang_tatoeba}-eng.eng \
|
||||
--outputs $datadir/test.bpe.${lang}-en_XX.${lang} $datadir/test.bpe.${lang}-en_XX.en_XX
|
||||
|
||||
# binarize data
|
||||
fairseq-preprocess \
|
||||
--source-lang ${lang} --target-lang en_XX \
|
||||
--testpref $datadir/test.bpe.${lang}-en_XX \
|
||||
--destdir $datadir \
|
||||
--srcdict ${DICT} \
|
||||
--joined-dictionary \
|
||||
--workers 4
|
||||
done
|
||||
@@ -0,0 +1,240 @@
|
||||
#!/usr/bin/env python3 -u
|
||||
# Copyright (c) Facebook, Inc. and its affiliates.
|
||||
#
|
||||
# This source code is licensed under the MIT license found in the
|
||||
# LICENSE file in the root directory of this source tree.
|
||||
import argparse
|
||||
import glob
|
||||
from subprocess import check_call
|
||||
|
||||
try:
|
||||
import faiss
|
||||
|
||||
has_faiss = True
|
||||
except ImportError:
|
||||
has_faiss = False
|
||||
import numpy as np
|
||||
|
||||
|
||||
GB = 1024 * 1024 * 1024
|
||||
|
||||
|
||||
def call(cmd):
|
||||
print(cmd)
|
||||
check_call(cmd, shell=True)
|
||||
|
||||
|
||||
def get_batches(directory, lang, prefix="all_avg_pool"):
|
||||
print(f"Finding in {directory}/{prefix}.{lang}*")
|
||||
files = glob.glob(f"{directory}/{prefix}.{lang}*")
|
||||
emb_files = []
|
||||
txt_files = []
|
||||
for emb_fi in files:
|
||||
emb_files.append(emb_fi)
|
||||
txt_fi = emb_fi.replace(prefix, "sentences")
|
||||
txt_files.append(txt_fi)
|
||||
return emb_files, txt_files
|
||||
|
||||
|
||||
def load_batch(emb_file, dim):
|
||||
embeddings = np.fromfile(emb_file, dtype=np.float32)
|
||||
num_rows = int(embeddings.shape[0] / dim)
|
||||
embeddings = embeddings.reshape((num_rows, dim))
|
||||
faiss.normalize_L2(embeddings)
|
||||
return embeddings
|
||||
|
||||
|
||||
def knnGPU_sharded(x_batches_f, y_batches_f, dim, k, direction="x2y"):
|
||||
if not has_faiss:
|
||||
raise ImportError("Please install Faiss")
|
||||
sims = []
|
||||
inds = []
|
||||
xfrom = 0
|
||||
xto = 0
|
||||
for x_batch_f in x_batches_f:
|
||||
yfrom = 0
|
||||
yto = 0
|
||||
x_batch = load_batch(x_batch_f, dim)
|
||||
xto = xfrom + x_batch.shape[0]
|
||||
bsims, binds = [], []
|
||||
for y_batch_f in y_batches_f:
|
||||
y_batch = load_batch(y_batch_f, dim)
|
||||
neighbor_size = min(k, y_batch.shape[0])
|
||||
yto = yfrom + y_batch.shape[0]
|
||||
print("{}-{} -> {}-{}".format(xfrom, xto, yfrom, yto))
|
||||
idx = faiss.IndexFlatIP(dim)
|
||||
idx = faiss.index_cpu_to_all_gpus(idx)
|
||||
idx.add(y_batch)
|
||||
bsim, bind = idx.search(x_batch, neighbor_size)
|
||||
|
||||
bsims.append(bsim)
|
||||
binds.append(bind + yfrom)
|
||||
yfrom += y_batch.shape[0]
|
||||
del idx
|
||||
del y_batch
|
||||
bsims = np.concatenate(bsims, axis=1)
|
||||
binds = np.concatenate(binds, axis=1)
|
||||
aux = np.argsort(-bsims, axis=1)
|
||||
sim_batch = np.zeros((x_batch.shape[0], k), dtype=np.float32)
|
||||
ind_batch = np.zeros((x_batch.shape[0], k), dtype=np.int64)
|
||||
for i in range(x_batch.shape[0]):
|
||||
for j in range(k):
|
||||
sim_batch[i, j] = bsims[i, aux[i, j]]
|
||||
ind_batch[i, j] = binds[i, aux[i, j]]
|
||||
sims.append(sim_batch)
|
||||
inds.append(ind_batch)
|
||||
xfrom += x_batch.shape[0]
|
||||
del x_batch
|
||||
sim = np.concatenate(sims, axis=0)
|
||||
ind = np.concatenate(inds, axis=0)
|
||||
return sim, ind
|
||||
|
||||
|
||||
def score(sim, fwd_mean, bwd_mean, margin):
|
||||
return margin(sim, (fwd_mean + bwd_mean) / 2)
|
||||
|
||||
|
||||
def score_candidates(
|
||||
sim_mat, candidate_inds, fwd_mean, bwd_mean, margin, verbose=False
|
||||
):
|
||||
print(" - scoring {:d} candidates".format(sim_mat.shape[0]))
|
||||
scores = np.zeros(candidate_inds.shape)
|
||||
for i in range(scores.shape[0]):
|
||||
for j in range(scores.shape[1]):
|
||||
k = int(candidate_inds[i, j])
|
||||
scores[i, j] = score(sim_mat[i, j], fwd_mean[i], bwd_mean[k], margin)
|
||||
return scores
|
||||
|
||||
|
||||
def load_text(files):
|
||||
all_sentences = []
|
||||
for fi in files:
|
||||
with open(fi) as sentence_fi:
|
||||
for line in sentence_fi:
|
||||
all_sentences.append(line.strip())
|
||||
print(f"Read {len(all_sentences)} sentences")
|
||||
return all_sentences
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Mine bitext")
|
||||
parser.add_argument("--src-lang", help="Source language")
|
||||
parser.add_argument("--tgt-lang", help="Target language")
|
||||
parser.add_argument(
|
||||
"--dict-path", help="Path to dictionary file", default="dict.txt"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--spm-path", help="Path to SPM model file", default="sentence.bpe.model"
|
||||
)
|
||||
parser.add_argument("--dim", type=int, default=1024, help="Embedding dimension")
|
||||
parser.add_argument("--mem", type=int, default=5, help="Memory in GB")
|
||||
parser.add_argument("--src-dir", help="Source directory")
|
||||
parser.add_argument("--tgt-dir", help="Target directory")
|
||||
parser.add_argument("--output", help="Output path")
|
||||
parser.add_argument(
|
||||
"--neighborhood", type=int, default=4, help="Embedding dimension"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--threshold", type=float, default=1.06, help="Threshold on mined bitext"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--valid-size",
|
||||
type=int,
|
||||
default=2000,
|
||||
help="Number of sentences used for validation set",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--min-count",
|
||||
type=int,
|
||||
default=50000,
|
||||
help="Min num sentences used for each language",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
x_batches_f, x_sents_f = get_batches(args.src_dir, args.src_lang)
|
||||
y_batches_f, y_sents_f = get_batches(args.tgt_dir, args.tgt_lang)
|
||||
margin = lambda a, b: a / b
|
||||
y2x_sim, y2x_ind = knnGPU_sharded(
|
||||
y_batches_f, x_batches_f, args.dim, args.neighborhood, direction="y2x"
|
||||
)
|
||||
x2y_sim, x2y_ind = knnGPU_sharded(
|
||||
x_batches_f, y_batches_f, args.dim, args.neighborhood, direction="x2y"
|
||||
)
|
||||
|
||||
x2y_mean = x2y_sim.mean(axis=1)
|
||||
y2x_mean = y2x_sim.mean(axis=1)
|
||||
fwd_scores = score_candidates(x2y_sim, x2y_ind, x2y_mean, y2x_mean, margin)
|
||||
bwd_scores = score_candidates(y2x_sim, y2x_ind, y2x_mean, x2y_mean, margin)
|
||||
fwd_best = x2y_ind[np.arange(x2y_sim.shape[0]), fwd_scores.argmax(axis=1)]
|
||||
bwd_best = y2x_ind[np.arange(y2x_sim.shape[0]), bwd_scores.argmax(axis=1)]
|
||||
indices = np.stack(
|
||||
(
|
||||
np.concatenate((np.arange(x2y_ind.shape[0]), bwd_best)),
|
||||
np.concatenate((fwd_best, np.arange(y2x_ind.shape[0]))),
|
||||
),
|
||||
axis=1,
|
||||
)
|
||||
scores = np.concatenate((fwd_scores.max(axis=1), bwd_scores.max(axis=1)))
|
||||
|
||||
x_sentences = load_text(x_sents_f)
|
||||
y_sentences = load_text(y_sents_f)
|
||||
|
||||
threshold = args.threshold
|
||||
min_count = args.min_count
|
||||
seen_src, seen_trg = set(), set()
|
||||
directory = args.output
|
||||
call(f"mkdir -p {directory}")
|
||||
src_out = open(
|
||||
f"{directory}/all.{args.src_lang}",
|
||||
mode="w",
|
||||
encoding="utf-8",
|
||||
errors="surrogateescape",
|
||||
)
|
||||
tgt_out = open(
|
||||
f"{directory}/all.{args.tgt_lang}",
|
||||
mode="w",
|
||||
encoding="utf-8",
|
||||
errors="surrogateescape",
|
||||
)
|
||||
scores_out = open(
|
||||
f"{directory}/all.scores", mode="w", encoding="utf-8", errors="surrogateescape"
|
||||
)
|
||||
count = 0
|
||||
for i in np.argsort(-scores):
|
||||
src_ind, trg_ind = indices[i]
|
||||
if src_ind not in seen_src and trg_ind not in seen_trg:
|
||||
seen_src.add(src_ind)
|
||||
seen_trg.add(trg_ind)
|
||||
if scores[i] > threshold or count < min_count:
|
||||
if x_sentences[src_ind]:
|
||||
print(scores[i], file=scores_out)
|
||||
print(x_sentences[src_ind], file=src_out)
|
||||
print(y_sentences[trg_ind], file=tgt_out)
|
||||
count += 1
|
||||
else:
|
||||
print(f"Ignoring sentence: {x_sentences[src_ind]}")
|
||||
src_out.close()
|
||||
tgt_out.close()
|
||||
scores_out.close()
|
||||
|
||||
print(f"Found {count} pairs for threshold={threshold}")
|
||||
with open(f"{directory}/all.{args.src_lang}") as all_s, open(
|
||||
f"{directory}/all.{args.tgt_lang}"
|
||||
) as all_t, open(f"{directory}/valid.{args.src_lang}", "w") as valid_s, open(
|
||||
f"{directory}/valid.{args.tgt_lang}", "w"
|
||||
) as valid_t, open(
|
||||
f"{directory}/train.{args.src_lang}", "w"
|
||||
) as train_s, open(
|
||||
f"{directory}/train.{args.tgt_lang}", "w"
|
||||
) as train_t:
|
||||
count = 0
|
||||
for s_line, t_line in zip(all_s, all_t):
|
||||
s_line = s_line.split("\t")[1]
|
||||
t_line = t_line.split("\t")[1]
|
||||
if count >= args.valid_size:
|
||||
train_s.write(s_line)
|
||||
train_t.write(t_line)
|
||||
else:
|
||||
valid_s.write(s_line)
|
||||
valid_t.write(t_line)
|
||||
count += 1
|
||||
@@ -0,0 +1,103 @@
|
||||
#!/bin/bash
|
||||
# Copyright (c) Facebook, Inc. and its affiliates.
|
||||
# All rights reserved.
|
||||
#
|
||||
# This source code is licensed under the license found in the
|
||||
# LICENSE file in the root directory of this source tree.
|
||||
#
|
||||
source_lang=kk_KZ
|
||||
target_lang=en_XX
|
||||
MODEL=criss_checkpoints/criss.3rd.pt
|
||||
SPM=criss_checkpoints/sentence.bpe.model
|
||||
SPLIT=test
|
||||
LANG_DICT=criss_checkpoints/lang_dict.txt
|
||||
SPM_ENCODE=flores/scripts/spm_encode.py
|
||||
SAVE_ENCODER=save_encoder.py
|
||||
ENCODER_SAVE_ROOT=sentence_embeddings/$MODEL
|
||||
DICT=criss_checkpoints/dict.txt
|
||||
THRESHOLD=1.02
|
||||
MIN_COUNT=500
|
||||
|
||||
DATA_DIR=data_tmp
|
||||
SAVE_DIR=mining/${source_lang}_${target_lang}_mined
|
||||
ENCODER_SAVE_DIR=${ENCODER_SAVE_ROOT}/${source_lang}-${target_lang}
|
||||
INPUT_DIR=$DATA_DIR/${source_lang}-${target_lang}-tatoeba
|
||||
|
||||
mkdir -p $ENCODER_SAVE_DIR/${target_lang}
|
||||
mkdir -p $ENCODER_SAVE_DIR/${source_lang}
|
||||
mkdir -p $SAVE_DIR
|
||||
|
||||
## Save encoder outputs
|
||||
|
||||
# Save encoder outputs for source sentences
|
||||
python $SAVE_ENCODER \
|
||||
${INPUT_DIR} \
|
||||
--path ${MODEL} \
|
||||
--task translation_multi_simple_epoch \
|
||||
--lang-pairs ${source_lang}-${target_lang} \
|
||||
--lang-dict ${LANG_DICT} \
|
||||
--gen-subset ${SPLIT} \
|
||||
--bpe 'sentencepiece' \
|
||||
-s ${source_lang} -t ${target_lang} \
|
||||
--sentencepiece-model ${SPM} \
|
||||
--remove-bpe 'sentencepiece' \
|
||||
--beam 1 \
|
||||
--lang-tok-style mbart \
|
||||
--encoder-save-dir ${ENCODER_SAVE_DIR}/${source_lang}
|
||||
|
||||
## Save encoder outputs for target sentences
|
||||
python $SAVE_ENCODER \
|
||||
${INPUT_DIR} \
|
||||
--path ${MODEL} \
|
||||
--lang-pairs ${source_lang}-${target_lang} \
|
||||
--lang-dict ${LANG_DICT} \
|
||||
--task translation_multi_simple_epoch \
|
||||
--gen-subset ${SPLIT} \
|
||||
--bpe 'sentencepiece' \
|
||||
-t ${source_lang} -s ${target_lang} \
|
||||
--sentencepiece-model ${SPM} \
|
||||
--remove-bpe 'sentencepiece' \
|
||||
--beam 1 \
|
||||
--lang-tok-style mbart \
|
||||
--encoder-save-dir ${ENCODER_SAVE_DIR}/${target_lang}
|
||||
|
||||
## Mining
|
||||
python mining/mine.py \
|
||||
--src-lang ${source_lang} \
|
||||
--tgt-lang ${target_lang} \
|
||||
--dim 1024 \
|
||||
--mem 10 \
|
||||
--neighborhood 4 \
|
||||
--src-dir ${ENCODER_SAVE_DIR}/${source_lang} \
|
||||
--tgt-dir ${ENCODER_SAVE_DIR}/${target_lang} \
|
||||
--output $SAVE_DIR \
|
||||
--threshold ${THRESHOLD} \
|
||||
--min-count ${MIN_COUNT} \
|
||||
--valid-size 100 \
|
||||
--dict-path ${DICT} \
|
||||
--spm-path ${SPM} \
|
||||
|
||||
|
||||
## Process and binarize mined data
|
||||
python $SPM_ENCODE \
|
||||
--model ${SPM} \
|
||||
--output_format=piece \
|
||||
--inputs mining/${source_lang}_${target_lang}_mined/train.${source_lang} mining/${source_lang}_${target_lang}_mined/train.${target_lang} \
|
||||
--outputs mining/${source_lang}_${target_lang}_mined/train.bpe.${source_lang} mining/${source_lang}_${target_lang}_mined/train.bpe.${target_lang}
|
||||
|
||||
python $SPM_ENCODE \
|
||||
--model ${SPM} \
|
||||
--output_format=piece \
|
||||
--inputs mining/${source_lang}_${target_lang}_mined/valid.${source_lang} mining/${source_lang}_${target_lang}_mined/valid.${target_lang} \
|
||||
--outputs mining/${source_lang}_${target_lang}_mined/valid.bpe.${source_lang} mining/${source_lang}_${target_lang}_mined/valid.bpe.${target_lang}
|
||||
|
||||
|
||||
fairseq-preprocess \
|
||||
--source-lang ${source_lang} \
|
||||
--target-lang ${target_lang} \
|
||||
--trainpref mining/${source_lang}_${target_lang}_mined/train.bpe \
|
||||
--validpref mining/${source_lang}_${target_lang}_mined/valid.bpe \
|
||||
--destdir mining/${source_lang}_${target_lang}_mined \
|
||||
--srcdict ${DICT} \
|
||||
--joined-dictionary \
|
||||
--workers 8
|
||||
@@ -0,0 +1,213 @@
|
||||
#!/usr/bin/env python3 -u
|
||||
# Copyright (c) Facebook, Inc. and its affiliates.
|
||||
#
|
||||
# This source code is licensed under the MIT license found in the
|
||||
# LICENSE file in the root directory of this source tree.
|
||||
"""
|
||||
Translate pre-processed data with a trained model.
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
from fairseq import checkpoint_utils, options, progress_bar, tasks, utils
|
||||
from fairseq.sequence_generator import EnsembleModel
|
||||
|
||||
|
||||
def get_avg_pool(
|
||||
models, sample, prefix_tokens, src_dict, remove_bpe, has_langtok=False
|
||||
):
|
||||
model = EnsembleModel(models)
|
||||
|
||||
# model.forward normally channels prev_output_tokens into the decoder
|
||||
# separately, but SequenceGenerator directly calls model.encoder
|
||||
encoder_input = {
|
||||
k: v for k, v in sample["net_input"].items() if k != "prev_output_tokens"
|
||||
}
|
||||
|
||||
# compute the encoder output for each beam
|
||||
encoder_outs = model.forward_encoder(encoder_input)
|
||||
np_encoder_outs = encoder_outs[0].encoder_out.cpu().numpy().astype(np.float32)
|
||||
encoder_mask = 1 - encoder_outs[0].encoder_padding_mask.cpu().numpy().astype(
|
||||
np.float32
|
||||
)
|
||||
encoder_mask = np.expand_dims(encoder_mask.T, axis=2)
|
||||
if has_langtok:
|
||||
encoder_mask = encoder_mask[1:, :, :]
|
||||
np_encoder_outs = np_encoder_outs[1, :, :]
|
||||
masked_encoder_outs = encoder_mask * np_encoder_outs
|
||||
avg_pool = (masked_encoder_outs / encoder_mask.sum(axis=0)).sum(axis=0)
|
||||
return avg_pool
|
||||
|
||||
|
||||
def main(args):
|
||||
assert args.path is not None, "--path required for generation!"
|
||||
assert (
|
||||
not args.sampling or args.nbest == args.beam
|
||||
), "--sampling requires --nbest to be equal to --beam"
|
||||
assert (
|
||||
args.replace_unk is None or args.raw_text
|
||||
), "--replace-unk requires a raw text dataset (--raw-text)"
|
||||
|
||||
args.beam = 1
|
||||
utils.import_user_module(args)
|
||||
|
||||
if args.max_tokens is None:
|
||||
args.max_tokens = 12000
|
||||
print(args)
|
||||
use_cuda = torch.cuda.is_available() and not args.cpu
|
||||
|
||||
# Load dataset splits
|
||||
task = tasks.setup_task(args)
|
||||
task.load_dataset(args.gen_subset)
|
||||
|
||||
# Set dictionaries
|
||||
try:
|
||||
src_dict = getattr(task, "source_dictionary", None)
|
||||
except NotImplementedError:
|
||||
src_dict = None
|
||||
tgt_dict = task.target_dictionary
|
||||
|
||||
# Load ensemble
|
||||
print("| loading model(s) from {}".format(args.path))
|
||||
models, _model_args = checkpoint_utils.load_model_ensemble(
|
||||
args.path.split(":"),
|
||||
arg_overrides=eval(args.model_overrides),
|
||||
task=task,
|
||||
)
|
||||
|
||||
# Optimize ensemble for generation
|
||||
for model in models:
|
||||
model.make_generation_fast_(
|
||||
beamable_mm_beam_size=None if args.no_beamable_mm else args.beam,
|
||||
need_attn=args.print_alignment,
|
||||
)
|
||||
if args.fp16:
|
||||
model.half()
|
||||
if use_cuda:
|
||||
model.cuda()
|
||||
|
||||
# Load alignment dictionary for unknown word replacement
|
||||
# (None if no unknown word replacement, empty if no path to align dictionary)
|
||||
align_dict = utils.load_align_dict(args.replace_unk)
|
||||
|
||||
# Load dataset (possibly sharded)
|
||||
itr = task.get_batch_iterator(
|
||||
dataset=task.dataset(args.gen_subset),
|
||||
max_tokens=args.max_tokens,
|
||||
max_positions=utils.resolve_max_positions(
|
||||
task.max_positions(),
|
||||
),
|
||||
ignore_invalid_inputs=args.skip_invalid_size_inputs_valid_test,
|
||||
required_batch_size_multiple=args.required_batch_size_multiple,
|
||||
num_shards=args.num_shards,
|
||||
shard_id=args.shard_id,
|
||||
num_workers=args.num_workers,
|
||||
).next_epoch_itr(shuffle=False)
|
||||
|
||||
num_sentences = 0
|
||||
source_sentences = []
|
||||
shard_id = 0
|
||||
all_avg_pool = None
|
||||
encoder_has_langtok = (
|
||||
hasattr(task.args, "encoder_langtok")
|
||||
and task.args.encoder_langtok is not None
|
||||
and hasattr(task.args, "lang_tok_replacing_bos_eos")
|
||||
and not task.args.lang_tok_replacing_bos_eos
|
||||
)
|
||||
with progress_bar.build_progress_bar(args, itr) as t:
|
||||
for sample in t:
|
||||
if sample is None:
|
||||
print("Skipping None")
|
||||
continue
|
||||
sample = utils.move_to_cuda(sample) if use_cuda else sample
|
||||
if "net_input" not in sample:
|
||||
continue
|
||||
|
||||
prefix_tokens = None
|
||||
if args.prefix_size > 0:
|
||||
prefix_tokens = sample["target"][:, : args.prefix_size]
|
||||
|
||||
with torch.no_grad():
|
||||
avg_pool = get_avg_pool(
|
||||
models,
|
||||
sample,
|
||||
prefix_tokens,
|
||||
src_dict,
|
||||
args.post_process,
|
||||
has_langtok=encoder_has_langtok,
|
||||
)
|
||||
if all_avg_pool is not None:
|
||||
all_avg_pool = np.concatenate((all_avg_pool, avg_pool))
|
||||
else:
|
||||
all_avg_pool = avg_pool
|
||||
|
||||
if not isinstance(sample["id"], list):
|
||||
sample_ids = sample["id"].tolist()
|
||||
else:
|
||||
sample_ids = sample["id"]
|
||||
for i, sample_id in enumerate(sample_ids):
|
||||
# Remove padding
|
||||
src_tokens = utils.strip_pad(
|
||||
sample["net_input"]["src_tokens"][i, :], tgt_dict.pad()
|
||||
)
|
||||
|
||||
# Either retrieve the original sentences or regenerate them from tokens.
|
||||
if align_dict is not None:
|
||||
src_str = task.dataset(args.gen_subset).src.get_original_text(
|
||||
sample_id
|
||||
)
|
||||
else:
|
||||
if src_dict is not None:
|
||||
src_str = src_dict.string(src_tokens, args.post_process)
|
||||
else:
|
||||
src_str = ""
|
||||
|
||||
if not args.quiet:
|
||||
if src_dict is not None:
|
||||
print("S-{}\t{}".format(sample_id, src_str))
|
||||
|
||||
source_sentences.append(f"{sample_id}\t{src_str}")
|
||||
|
||||
num_sentences += sample["nsentences"]
|
||||
if all_avg_pool.shape[0] >= 1000000:
|
||||
with open(
|
||||
f"{args.encoder_save_dir}/all_avg_pool.{args.source_lang}.{shard_id}",
|
||||
"w",
|
||||
) as avg_pool_file:
|
||||
all_avg_pool.tofile(avg_pool_file)
|
||||
with open(
|
||||
f"{args.encoder_save_dir}/sentences.{args.source_lang}.{shard_id}",
|
||||
"w",
|
||||
) as sentence_file:
|
||||
sentence_file.writelines(f"{line}\n" for line in source_sentences)
|
||||
all_avg_pool = None
|
||||
source_sentences = []
|
||||
shard_id += 1
|
||||
|
||||
if all_avg_pool is not None:
|
||||
with open(
|
||||
f"{args.encoder_save_dir}/all_avg_pool.{args.source_lang}.{shard_id}", "w"
|
||||
) as avg_pool_file:
|
||||
all_avg_pool.tofile(avg_pool_file)
|
||||
with open(
|
||||
f"{args.encoder_save_dir}/sentences.{args.source_lang}.{shard_id}", "w"
|
||||
) as sentence_file:
|
||||
sentence_file.writelines(f"{line}\n" for line in source_sentences)
|
||||
return None
|
||||
|
||||
|
||||
def cli_main():
|
||||
parser = options.get_generation_parser()
|
||||
parser.add_argument(
|
||||
"--encoder-save-dir",
|
||||
default="",
|
||||
type=str,
|
||||
metavar="N",
|
||||
help="directory to save encoder outputs",
|
||||
)
|
||||
args = options.parse_args_and_arch(parser)
|
||||
main(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
cli_main()
|
||||
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env python3 -u
|
||||
# Copyright (c) Facebook, Inc. and its affiliates.
|
||||
#
|
||||
# This source code is licensed under the MIT license found in the
|
||||
# LICENSE file in the root directory of this source tree.
|
||||
import argparse
|
||||
import glob
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
DIM = 1024
|
||||
|
||||
|
||||
def compute_dist(source_embs, target_embs, k=5, return_sim_mat=False):
|
||||
target_ids = [tid for tid in target_embs]
|
||||
source_mat = np.stack(source_embs.values(), axis=0)
|
||||
normalized_source_mat = source_mat / np.linalg.norm(
|
||||
source_mat, axis=1, keepdims=True
|
||||
)
|
||||
target_mat = np.stack(target_embs.values(), axis=0)
|
||||
normalized_target_mat = target_mat / np.linalg.norm(
|
||||
target_mat, axis=1, keepdims=True
|
||||
)
|
||||
sim_mat = normalized_source_mat.dot(normalized_target_mat.T)
|
||||
if return_sim_mat:
|
||||
return sim_mat
|
||||
neighbors_map = {}
|
||||
for i, sentence_id in enumerate(source_embs):
|
||||
idx = np.argsort(sim_mat[i, :])[::-1][:k]
|
||||
neighbors_map[sentence_id] = [target_ids[tid] for tid in idx]
|
||||
return neighbors_map
|
||||
|
||||
|
||||
def load_embeddings(directory, LANGS):
|
||||
sentence_embeddings = {}
|
||||
sentence_texts = {}
|
||||
for lang in LANGS:
|
||||
sentence_embeddings[lang] = {}
|
||||
sentence_texts[lang] = {}
|
||||
lang_dir = f"{directory}/{lang}"
|
||||
embedding_files = glob.glob(f"{lang_dir}/all_avg_pool.{lang}.*")
|
||||
for embed_file in embedding_files:
|
||||
shard_id = embed_file.split(".")[-1]
|
||||
embeddings = np.fromfile(embed_file, dtype=np.float32)
|
||||
num_rows = embeddings.shape[0] // DIM
|
||||
embeddings = embeddings.reshape((num_rows, DIM))
|
||||
|
||||
with open(f"{lang_dir}/sentences.{lang}.{shard_id}") as sentence_file:
|
||||
for idx, line in enumerate(sentence_file):
|
||||
sentence_id, sentence = line.strip().split("\t")
|
||||
sentence_texts[lang][sentence_id] = sentence
|
||||
sentence_embeddings[lang][sentence_id] = embeddings[idx, :]
|
||||
|
||||
return sentence_embeddings, sentence_texts
|
||||
|
||||
|
||||
def compute_accuracy(directory, LANGS):
|
||||
sentence_embeddings, sentence_texts = load_embeddings(directory, LANGS)
|
||||
|
||||
top_1_accuracy = {}
|
||||
|
||||
top1_str = " ".join(LANGS) + "\n"
|
||||
for source_lang in LANGS:
|
||||
top_1_accuracy[source_lang] = {}
|
||||
top1_str += f"{source_lang} "
|
||||
for target_lang in LANGS:
|
||||
top1 = 0
|
||||
top5 = 0
|
||||
neighbors_map = compute_dist(
|
||||
sentence_embeddings[source_lang], sentence_embeddings[target_lang]
|
||||
)
|
||||
for sentence_id, neighbors in neighbors_map.items():
|
||||
if sentence_id == neighbors[0]:
|
||||
top1 += 1
|
||||
if sentence_id in neighbors[:5]:
|
||||
top5 += 1
|
||||
n = len(sentence_embeddings[target_lang])
|
||||
top1_str += f"{top1/n} "
|
||||
top1_str += "\n"
|
||||
|
||||
print(top1_str)
|
||||
print(top1_str, file=open(f"{directory}/accuracy", "w"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Analyze encoder outputs")
|
||||
parser.add_argument("directory", help="Source language corpus")
|
||||
parser.add_argument("--langs", help="List of langs")
|
||||
args = parser.parse_args()
|
||||
langs = args.langs.split(",")
|
||||
compute_accuracy(args.directory, langs)
|
||||
@@ -0,0 +1,59 @@
|
||||
#!/bin/bash
|
||||
# Copyright (c) Facebook, Inc. and its affiliates.
|
||||
# All rights reserved.
|
||||
#
|
||||
# This source code is licensed under the license found in the
|
||||
# LICENSE file in the root directory of this source tree.
|
||||
#
|
||||
source_lang=kk_KZ
|
||||
target_lang=en_XX
|
||||
MODEL=criss_checkpoints/criss.3rd.pt
|
||||
SPM=criss_checkpoints/sentence.bpe.model
|
||||
SPLIT=test
|
||||
LANG_DICT=criss_checkpoints/lang_dict.txt
|
||||
ENCODER_ANALYSIS=sentence_retrieval/encoder_analysis.py
|
||||
SAVE_ENCODER=save_encoder.py
|
||||
ENCODER_SAVE_ROOT=sentence_embeddings/$MODEL
|
||||
|
||||
|
||||
|
||||
DATA_DIR=data_tmp
|
||||
INPUT_DIR=$DATA_DIR/${source_lang}-${target_lang}-tatoeba
|
||||
ENCODER_SAVE_DIR=${ENCODER_SAVE_ROOT}/${source_lang}-${target_lang}
|
||||
mkdir -p $ENCODER_SAVE_DIR/${target_lang}
|
||||
mkdir -p $ENCODER_SAVE_DIR/${source_lang}
|
||||
|
||||
# Save encoder outputs for source sentences
|
||||
python $SAVE_ENCODER \
|
||||
${INPUT_DIR} \
|
||||
--path ${MODEL} \
|
||||
--task translation_multi_simple_epoch \
|
||||
--lang-dict ${LANG_DICT} \
|
||||
--gen-subset ${SPLIT} \
|
||||
--bpe 'sentencepiece' \
|
||||
--lang-pairs ${source_lang}-${target_lang} \
|
||||
-s ${source_lang} -t ${target_lang} \
|
||||
--sentencepiece-model ${SPM} \
|
||||
--remove-bpe 'sentencepiece' \
|
||||
--beam 1 \
|
||||
--lang-tok-style mbart \
|
||||
--encoder-save-dir ${ENCODER_SAVE_DIR}/${source_lang}
|
||||
|
||||
# Save encoder outputs for target sentences
|
||||
python $SAVE_ENCODER \
|
||||
${INPUT_DIR} \
|
||||
--path ${MODEL} \
|
||||
--lang-dict ${LANG_DICT} \
|
||||
--task translation_multi_simple_epoch \
|
||||
--gen-subset ${SPLIT} \
|
||||
--bpe 'sentencepiece' \
|
||||
--lang-pairs ${target_lang}-${source_lang} \
|
||||
-t ${source_lang} -s ${target_lang} \
|
||||
--sentencepiece-model ${SPM} \
|
||||
--remove-bpe 'sentencepiece' \
|
||||
--beam 1 \
|
||||
--lang-tok-style mbart \
|
||||
--encoder-save-dir ${ENCODER_SAVE_DIR}/${target_lang}
|
||||
|
||||
# Analyze sentence retrieval accuracy
|
||||
python $ENCODER_ANALYSIS --langs "${source_lang},${target_lang}" ${ENCODER_SAVE_DIR}
|
||||
@@ -0,0 +1,37 @@
|
||||
#!/bin/bash
|
||||
# Copyright (c) Facebook, Inc. and its affiliates.
|
||||
# All rights reserved.
|
||||
#
|
||||
# This source code is licensed under the license found in the
|
||||
# LICENSE file in the root directory of this source tree.
|
||||
#
|
||||
SRC=si_LK
|
||||
TGT=en_XX
|
||||
MODEL=criss_checkpoints/criss.3rd.pt
|
||||
|
||||
MULTIBLEU=mosesdecoder/scripts/generic/multi-bleu.perl
|
||||
MOSES=mosesdecoder
|
||||
REPLACE_UNICODE_PUNCT=$MOSES/scripts/tokenizer/replace-unicode-punctuation.perl
|
||||
NORM_PUNC=$MOSES/scripts/tokenizer/normalize-punctuation.perl
|
||||
REM_NON_PRINT_CHAR=$MOSES/scripts/tokenizer/remove-non-printing-char.perl
|
||||
TOKENIZER=$MOSES/scripts/tokenizer/tokenizer.perl
|
||||
GEN_TMP_DIR=gen_tmp
|
||||
LANG_DICT=criss_checkpoints/lang_dict.txt
|
||||
|
||||
if [ ! -d "mosesdecoder" ]; then
|
||||
git clone https://github.com/moses-smt/mosesdecoder
|
||||
fi
|
||||
mkdir -p $GEN_TMP_DIR
|
||||
fairseq-generate data_tmp/${SRC}-${TGT}-flores \
|
||||
--task translation_multi_simple_epoch \
|
||||
--max-tokens 2000 \
|
||||
--path ${MODEL} \
|
||||
--skip-invalid-size-inputs-valid-test \
|
||||
--beam 5 --lenpen 1.0 --gen-subset test \
|
||||
--remove-bpe=sentencepiece \
|
||||
--source-lang ${SRC} --target-lang ${TGT} \
|
||||
--decoder-langtok --lang-pairs 'en_XX-ar_AR,en_XX-de_DE,en_XX-es_XX,en_XX-fr_XX,en_XX-hi_IN,en_XX-it_IT,en_XX-ja_XX,en_XX-ko_KR,en_XX-nl_XX,en_XX-ru_RU,en_XX-zh_CN,en_XX-tr_TR,en_XX-vi_VN,en_XX-ro_RO,en_XX-my_MM,en_XX-ne_NP,en_XX-si_LK,en_XX-cs_CZ,en_XX-lt_LT,en_XX-kk_KZ,en_XX-gu_IN,en_XX-fi_FI,en_XX-et_EE,en_XX-lv_LV,ar_AR-en_XX,cs_CZ-en_XX,de_DE-en_XX,es_XX-en_XX,et_EE-en_XX,fi_FI-en_XX,fr_XX-en_XX,gu_IN-en_XX,hi_IN-en_XX,it_IT-en_XX,ja_XX-en_XX,kk_KZ-en_XX,ko_KR-en_XX,lt_LT-en_XX,lv_LV-en_XX,my_MM-en_XX,ne_NP-en_XX,nl_XX-en_XX,ro_RO-en_XX,ru_RU-en_XX,si_LK-en_XX,tr_TR-en_XX,vi_VN-en_XX,zh_CN-en_XX,ar_AR-es_XX,es_XX-ar_AR,ar_AR-hi_IN,hi_IN-ar_AR,ar_AR-zh_CN,zh_CN-ar_AR,cs_CZ-es_XX,es_XX-cs_CZ,cs_CZ-hi_IN,hi_IN-cs_CZ,cs_CZ-zh_CN,zh_CN-cs_CZ,de_DE-es_XX,es_XX-de_DE,de_DE-hi_IN,hi_IN-de_DE,de_DE-zh_CN,zh_CN-de_DE,es_XX-hi_IN,hi_IN-es_XX,es_XX-zh_CN,zh_CN-es_XX,et_EE-es_XX,es_XX-et_EE,et_EE-hi_IN,hi_IN-et_EE,et_EE-zh_CN,zh_CN-et_EE,fi_FI-es_XX,es_XX-fi_FI,fi_FI-hi_IN,hi_IN-fi_FI,fi_FI-zh_CN,zh_CN-fi_FI,fr_XX-es_XX,es_XX-fr_XX,fr_XX-hi_IN,hi_IN-fr_XX,fr_XX-zh_CN,zh_CN-fr_XX,gu_IN-es_XX,es_XX-gu_IN,gu_IN-hi_IN,hi_IN-gu_IN,gu_IN-zh_CN,zh_CN-gu_IN,hi_IN-zh_CN,zh_CN-hi_IN,it_IT-es_XX,es_XX-it_IT,it_IT-hi_IN,hi_IN-it_IT,it_IT-zh_CN,zh_CN-it_IT,ja_XX-es_XX,es_XX-ja_XX,ja_XX-hi_IN,hi_IN-ja_XX,ja_XX-zh_CN,zh_CN-ja_XX,kk_KZ-es_XX,es_XX-kk_KZ,kk_KZ-hi_IN,hi_IN-kk_KZ,kk_KZ-zh_CN,zh_CN-kk_KZ,ko_KR-es_XX,es_XX-ko_KR,ko_KR-hi_IN,hi_IN-ko_KR,ko_KR-zh_CN,zh_CN-ko_KR,lt_LT-es_XX,es_XX-lt_LT,lt_LT-hi_IN,hi_IN-lt_LT,lt_LT-zh_CN,zh_CN-lt_LT,lv_LV-es_XX,es_XX-lv_LV,lv_LV-hi_IN,hi_IN-lv_LV,lv_LV-zh_CN,zh_CN-lv_LV,my_MM-es_XX,es_XX-my_MM,my_MM-hi_IN,hi_IN-my_MM,my_MM-zh_CN,zh_CN-my_MM,ne_NP-es_XX,es_XX-ne_NP,ne_NP-hi_IN,hi_IN-ne_NP,ne_NP-zh_CN,zh_CN-ne_NP,nl_XX-es_XX,es_XX-nl_XX,nl_XX-hi_IN,hi_IN-nl_XX,nl_XX-zh_CN,zh_CN-nl_XX,ro_RO-es_XX,es_XX-ro_RO,ro_RO-hi_IN,hi_IN-ro_RO,ro_RO-zh_CN,zh_CN-ro_RO,ru_RU-es_XX,es_XX-ru_RU,ru_RU-hi_IN,hi_IN-ru_RU,ru_RU-zh_CN,zh_CN-ru_RU,si_LK-es_XX,es_XX-si_LK,si_LK-hi_IN,hi_IN-si_LK,si_LK-zh_CN,zh_CN-si_LK,tr_TR-es_XX,es_XX-tr_TR,tr_TR-hi_IN,hi_IN-tr_TR,tr_TR-zh_CN,zh_CN-tr_TR,vi_VN-es_XX,es_XX-vi_VN,vi_VN-hi_IN,hi_IN-vi_VN,vi_VN-zh_CN,zh_CN-vi_VN' \
|
||||
--lang-dict ${LANG_DICT} --lang-tok-style 'mbart' --sampling-method 'temperature' --sampling-temperature '1.0' > $GEN_TMP_DIR/${SRC}_${TGT}.gen
|
||||
cat $GEN_TMP_DIR/${SRC}_${TGT}.gen | grep -P "^T-" | cut -f2 | $REPLACE_UNICODE_PUNCT | $NORM_PUNC -l ${TGT:0:2} | $REM_NON_PRINT_CHAR | $TOKENIZER -no-escape ${TGT:0:2} > $GEN_TMP_DIR/${SRC}_${TGT}.hyp
|
||||
cat $GEN_TMP_DIR/${SRC}_${TGT}.gen | grep -P "^H-" | cut -f3 | $REPLACE_UNICODE_PUNCT | $NORM_PUNC -l ${TGT:0:2} | $REM_NON_PRINT_CHAR | $TOKENIZER -no-escape ${TGT:0:2} > $GEN_TMP_DIR/${SRC}_${TGT}.ref
|
||||
${MULTIBLEU} $GEN_TMP_DIR/${SRC}_${TGT}.ref < $GEN_TMP_DIR/${SRC}_${TGT}.hyp
|
||||
Reference in New Issue
Block a user