Analiza częstości nazw altcoinów w korpusie języka angielskiego
Celem artykułu jest pokazanie jak odfiltrować spośród wszystkich nazw kryptowalut, te nie występujące w języku naturalnym.
Celem artykułu jest pokazanie jak odfiltrować spośród wszystkich nazw altcoinów, te nie występujące w języku naturalnym. Zastosowanie tej techniki pozwoliło nam na skuteczny monitoring wzmianek na temat tysięcy kryptowalut na Twitterze w projekcie MaxData
.
Plan działania:
- Musimy mieć nazwy kryptowalut. Pokażę jak je pobrać i uporządkować.
- Musimy mieć korpus języka. Pokażę jak się do niego dostać.
- Musimy połączyć oba zbiory danych i wyznaczyć kryterium odcięcia kryptowaluty z monitoringu.
Jeśli bardzo zależało by nam na obserwacji frazy występującej w języku naturalnym wymagało by to analizy kontekstu. Jest to oczywiście możliwe, ale w naszym przypadku prościej jest nam odciąć kilka altcoinów o nazwach występujących w korpusie niż analizować kontekst. Głównie dlatego, że te odcięte altcoiny stanowią niewielki ułamek całości rynku, a ich uwzględnienie podniosło by poziom skomplikowania wielokrotnie.
Nazwy altcoinów
Nazwy kryptowalut pobraliśmy z Coin Market Cap
za pomocą końcówki
https://api.coinmarketcap.com/data-api/v3/cryptocurrency/listing
z parametrem start
iterowanym po 1+100*n
dla n
od 0
do momentu gdzie odpowiedź nie będzie zawierała klucza data
.
Przykładowa dobra odpowiedź to:
{
"data": {
"cryptoCurrencyList": [
{
"id": 8138,
"name": "LinkBased",
"symbol": "LBD",
"slug": "linkbased",
"tags": [],
"cmcRank": 4601,
"marketPairCount": 1,
"circulatingSupply": 0E-8,
"totalSupply": 813923.00000000,
"isActive": 1,
"lastUpdated": "2021-06-26T09:08:12.000Z",
"dateAdded": "2020-12-30T00:00:00.000Z",
"quotes": [
{
"name": "USD",
"price": 1.59351133162663,
"volume24h": 514.07425485,
"marketCap": 0E-22,
"percentChange1h": -0.13208528,
"percentChange24h": -26.50872672,
"percentChange7d": -34.07116202,
"lastUpdated": "2021-06-26T09:08:12.000Z",
"percentChange30d": -56.37728930,
"percentChange60d": -57.50444478,
"percentChange90d": -46.98725744,
"fullyDilluttedMarketCap": 1296995.52,
"dominance": 0.0,
"ytdPriceChangePercentage": 41.3223
}
],
"isAudited": false
},
...
],
"totalCount": "5465"
},
"status": {
"timestamp": "2021-06-26T09:10:02.180Z",
"error_code": "0",
"error_message": "SUCCESS",
"elapsed": "134",
"credit_count": 0
}
}
A kiedy wyjdziemy poza zakres dostaniemy:
{
"status": {
"credit_count": 0,
"elapsed": "4",
"error_code": "500",
"error_message": "The system is busy, please try again later!",
"timestamp": "2021-06-26T09:07:58.780Z"
}
}
Najbardziej interesują nas parametry:
- name
- symbol
- quotes[0].marketCap albo jego znormalizowana wersja quotes[0].dominance
Pobierzemy wszystkie dane o kryptowalutach i zapiszemy je w pliku. Przygotowujemy projekt:
npm init -y && tsc --init && npm i axios && npm i -D @types/node && mkdir -p src raw out && touch src/getAltcoins.ts
Rdzeń programu getAltcoins.ts
możemy przenieść z naszego niedawnego wpisu:
Czyli mniej więcej tak:
import * as fs from "fs";
interface CmcCoin {
// todo implement
}
class Page {
i: number;
constructor(i: number) {
this.i = i;
}
url() {
return `https://api.coinmarketcap.com/data-api/v3/cryptocurrency/listing?start=${1 + 100 * this.i}`
}
file() {
return `${process.cwd()}/raw/${this.i}.json`
}
sync() {
// TODO implement
return false;
}
parse(): CmcCoin[] {
// todo implement
return []
}
}
const main = async ():Promise<CmcCoin[]> => {
let i = 0;
const allItems:CmcCoin[] = [];
while (await new Page(i).sync()) {
const items = new Page(i).parse()
if (items.length === 0) break;
allItems.push(...items);
i++;
}
return allItems;
}
main().then((coins) => {
fs.writeFileSync(process.cwd() + '/out/coins.json', JSON.stringify(coins));
console.log(coins);
}).catch(console.error)
Implementacja interfejsu CmcCoin
Najprostszą metodą jest przyjrzenie się temu co zwraca API dla Bitcoina:
{
"id": 1,
"name": "Bitcoin",
"symbol": "BTC",
"slug": "bitcoin",
"tags": [
"mineable",
"pow",
"sha-256",
"store-of-value",
"state-channels",
"coinbase-ventures-portfolio",
"three-arrows-capital-portfolio",
"polychain-capital-portfolio",
"binance-labs-portfolio",
"arrington-xrp-capital",
"blockchain-capital-portfolio",
"boostvc-portfolio",
"cms-holdings-portfolio",
"dcg-portfolio",
"dragonfly-capital-portfolio",
"electric-capital-portfolio",
"fabric-ventures-portfolio",
"framework-ventures",
"galaxy-digital-portfolio",
"huobi-capital",
"alameda-research-portfolio",
"a16z-portfolio",
"1confirmation-portfolio",
"winklevoss-capital",
"usv-portfolio",
"placeholder-ventures-portfolio",
"pantera-capital-portfolio",
"multicoin-capital-portfolio",
"paradigm-xzy-screener"
],
"cmcRank": 1,
"marketPairCount": 9193,
"circulatingSupply": 18742968,
"totalSupply": 18742968,
"maxSupply": 21000000,
"isActive": 1,
"lastUpdated": "2021-06-26T09:20:02.000Z",
"dateAdded": "2013-04-28T00:00:00.000Z",
"quotes": [
{
"name": "USD",
"price": 30407.151465830357,
"volume24h": 41711690274.967766,
"marketCap": 569920266895.2114,
"percentChange1h": 0.67834797,
"percentChange24h": -11.72063275,
"percentChange7d": -15.05133094,
"lastUpdated": "2021-06-26T09:20:02.000Z",
"percentChange30d": -22.4475165,
"percentChange60d": -44.25026974,
"percentChange90d": -46.26175604,
"fullyDilluttedMarketCap": 638550180782.44,
"dominance": 48.2033,
"turnover": 0.07318864,
"ytdPriceChangePercentage": 3.5167
}
],
"isAudited": false
}
i przerobienie tego na interfejs:
interface CmcCoin {
"id": number,
"name": string,
"symbol": string,
"slug": string,
"tags": string[],
"cmcRank": number,
"marketPairCount": number,
"circulatingSupply": number,
"totalSupply": number,
"maxSupply": number,
"isActive": number,
"lastUpdated": string,
"dateAdded": string,
"quotes": {
"name": string,
"price": number,
"volume24h": number,
"marketCap": number,
"percentChange1h": number,
"percentChange24h": number,
"percentChange7d": number,
"lastUpdated": string,
"percentChange30d": number,
"percentChange60d": number,
"percentChange90d": number,
"fullyDilluttedMarketCap": number,
"dominance": number,
"turnover": number,
"ytdPriceChangePercentage": number
}[],
"isAudited": boolean
}
Synchronizacja
Po dodaniu paczki debug
poleceniem
npm i debug && npm i -D @types/debug
i kilku importów
import axios from "axios";
import * as fs from "fs";
import Debug from 'debug';
const debug = Debug('app');
analogicznie jak w poprzednio wspomnianym artykule implementujemy sync
async sync() {
try {
const fileExists = fs.existsSync(this.file())
if (fileExists) return true;
const {data, status} = await axios.get(this.url());
if (status !== 200) return false;
fs.writeFileSync(this.file(), JSON.stringify(data));
debug(`Saved ${this.file()}`)
return true;
} catch (e) {
console.error(e)
return false;
}
}
Jedyną różnicą jest tu JSON.stringify
ponieważ chcemy zapisać do pliku ciąg znaków a nie obiekt. Tym razem korzystamy z api
a nie pobieramy html
.
Możemy napisać to nawet uniwersalniej
typeof data === 'string' ? data : JSON.stringify(data)
co pozwoli nam na używanie wielokrotnie tego raz napisanego kodu.
Parsowanie
Metoda do parsowania jest wyjątkowo prosta:
parse(): CmcCoin[] {
try {
const content = JSON.parse(fs.readFileSync(this.file()).toString());
return content.data.cryptoCurrencyList
} catch (e) {
return []
}
}
polega na próbie wydobycia listy pod określonym kluczem, a jeśli to niemożliwe zwraca pustą tablicę powodując zakończenie głównej pętli programu.
Finalnie po włączeniu programu:
DEBUG=app ts-node src/getAltcoins.ts
w katalogu out/coins.json
dostajemy plik, który zamieściłem pod linkiem:
https://preciselab.fra1.digitaloceanspaces.com/blog/scraping/coins.json
Pobranie i obsługa korpusu językowego
Po wpisaniu frazy "english corpus" bardzo szybko trafiamy na stronę
Jest to scam. Zawiera informację, że jest darmowa i wystarczy zarejestrować konto
ale posiada ograniczenia przez które możemy skanować dziennie jedynie 50 słów. Straciłem czas próbując automatyzować pobieranie danych z tego serwisu.
Pobranie z niego próbek prowadzi do tego, że mamy poszatkowane dane nie zdatne do żadnego zastosowania i dopiero wejście w cennik wyjaśnia, że można u nich kupić korpus za kilkaset dolarów.
Na szczęście udało mi się pobrać wymagane dane ze strony o znacznie gorszym pozycjonowaniu, ale za to dużo bardziej wartościowej:
Tam też rejestracja jest wymagana, ale w zamian dostajemy dostęp do ciekawych danych, interesujących treści i fantastycznego kursu. Nawet jak tego nie potrzebujemy to po prostu dane mamy za darmo. Jest to 5MB plik csv z kolumnami zawierającymi słowo oraz ilość zliczeń.
Umieściłem ten plik pod ścieżką dict/unigram_freq.csv
. Aby zapytać o ilość zliczeń słowa credit
wystarczy wpisać:
grep -E '^credit,' dict/unigram_freq.csv
dostajemy:
credit,175916536
Analogicznie dla frazy:
grep -E '^theta,' dict/unigram_freq.csv
mamy:
theta,5070673
Za pomocą typescriptu mogli byśmy zapisać to tak:
import child_process from 'child_process';
const grepWithFork = (filename: string, word: string): Buffer => {
const cmd = `egrep '^${word},' ${filename}`;
return child_process.execSync(cmd, {maxBuffer: 200000000})
}
export const checkFrequency = async (word: string): Promise<number> => {
return parseInt(grepWithFork(
process.cwd() + '/dict/unigram_freq.csv',
word
).toString().replace(`${word},`, '')) || 0;
}
checkFrequency('credit').then(console.log).catch(console.error)
checkFrequency('theta').then(console.log).catch(console.error)
wykonanie tego pliku zwróci nam częstości:
175916536
5070673
z tego co wiem, to wykorzystanie systemowego grepa jest jedną z najbardziej wydajnych metod w tym konkretnym przypadku, ponieważ nie wymaga ładowania całego pliku do pamięci, pisania logiki wyszukiwania a jednocześnie pozwala zrzucić odpowiedzialność za optymalizację wyszukiwania na twórców grep
. Sam nie robiłem takich eksperymentów, ale czytałem, że do 2-3 tysięcy linii można w node js wyszukać szybciej, bo nie tracimy czasu na włączanie osobnego procesu, ale przy większych plikach okazuje się, że optymalizacja grepa nadrabia opóźnienia związane z wykonywaniem komend przez child_process
.
Połączenie częstości z nazwami coinów
Wykonałem drobny refactoring. W src
utworzyłem katalogi interface
oraz helpers
. Do interface
przeniosłem CmcCoin
, oraz utworzyłem CoinWithFrequency.ts
zawierający
import {CmcCoin} from "./CmcCoin";
export interface CoinWithFrequency extends CmcCoin {
frequency: {
name: number,
symbol: number,
slug: number
}
}
jest to struktura danych pozwalająca nam ująć możliwie dokładne dane dotyczące częstotliwości występowania nie tylko nazw ale też symboli i potencjalnie slug
coinów.
Do helpers
przeniosłem klasę Page
, oraz funkcje grepWithFork
i checkFrequency
z tym, że ta druga dostała obsługę wyjątków:
import {grepWithFork} from "./grepWithFork";
export const checkFrequency = (word: string): number => {
try {
return parseInt(grepWithFork(
process.cwd() + '/dict/unigram_freq.csv',
word
).toString().replace(`${word},`, '')) || 0;
} catch (e) {
return 0
}
}
Ostatnią zmianą jest wyrzucenie z getAltcoins
funkcji main
i nazwanie jej getCoins
. W pliku o tej samej nazwie w helpers
znalazł się teraz kod
import {CmcCoin} from "../interface/CmcCoin";
import {Page} from "./Page";
export const getCoins = async ():Promise<CmcCoin[]> => {
let i = 0;
const allItems:CmcCoin[] = [];
while (await new Page(i).sync()) {
const items = new Page(i).parse()
if (items.length === 0) break;
allItems.push(...items);
i++;
}
return allItems;
}
Nową funkcją jest bardzo prosta funkcja enhanceSingleCoin
umieszczona też w helpers
w pliku z tą nazwą o treści:
import {CmcCoin} from "../interface/CmcCoin";
import {CoinWithFrequency} from "../interface/CoinWithFrequency";
import {checkFrequency} from "./checkFrequency";
export const enhanceSingleCoin = (coin: CmcCoin): CoinWithFrequency => {
return {
...coin,
frequency: {
name: checkFrequency(coin.name.toLowerCase()),
slug: checkFrequency(coin.slug.toLowerCase()),
symbol: checkFrequency(coin.symbol.toLowerCase())
}
}
}
Iterując za jej pomocą po tablicy walut przetwarzamy je kolejno
import {CoinWithFrequency} from "../interface/CoinWithFrequency";
import {getCoins} from "./getCoins";
import {enhanceSingleCoin} from "./enhanceSingleCoin";
export const enhanceCoins = async (): Promise<CoinWithFrequency[]> => {
const coins = await getCoins();
const res: CoinWithFrequency[] = []
let i = 0, s = new Date().getTime(), n = () => new Date().getTime() - s;
for (const coin of coins) {
res.push(enhanceSingleCoin(coin));
console.log(`${i++}\t${i/coins.length}\t${n()}`);
}
return res;
}
Ponieważ trwa to chwilę do funkcji dodałem proste wyświetlanie postępu oraz czasu wykonywania.
Nasz ostatni skrypt: enhanceCoinsByFrequenceis.ts
zawiera jedynie zapisanie wyników tej funkcji do pliku:
import fs from "fs";
import {enhanceCoins} from "./helpers/enhanceCoins";
enhanceCoins().then((coins) => {
fs.writeFileSync(process.cwd() + '/out/coins-with-freq.json', JSON.stringify(coins));
console.log(coins)
}).catch(console.error)
Po jego wykonaniu poleceniem
DEBUG=app ts-node src/enhanceCoinsByFrequenceis.ts
dostajemy plik z walutami wzbogaconymi o częstości /out/coins-with-freq.json
.
Sortowanie fraz
Przyjrzyjmy się teraz posortowanej względem stosunku quotes[0].marketCap
do parametrów określonych pod kluczem frequency
. Zaczniemy od ustalenia struktury danych wyjściowych:
import {CoinWithFrequency} from "./CoinWithFrequency";
export enum PhraseType {
slug = 'slug',
name = 'name',
symbol = 'symbol',
}
export interface Phrase {
coinId: number,
value: string,
capToFrequency: number,
type: PhraseType
coin?: CoinWithFrequency
}
Parametr coin
nie jest wymagany, bo zakładam, że dla celów analizy może się przydać, ale ilość danych w tym parametrze jest na tyle duża, że może się okazać, że warto oczyścić z niego ostateczny wynik.
Podstawową cegiełkę ostatniej fazy stanowi zamiana coinów na frazy
import {CoinWithFrequency} from "../interface/CoinWithFrequency";
import {Phrase, PhraseType} from "../interface/Phrase";
import {SortOptions} from "../interface/SortOptions";
export const convertCoinsToPhrases = (
coins: CoinWithFrequency[],
options: SortOptions = {withCoin: true}
): Phrase[] => {
const phrases: Phrase[] = [];
for (const coin of coins) {
const newPhrases = [PhraseType.name, PhraseType.slug, PhraseType.symbol]
.map((type: PhraseType): Phrase => {
return {
coinId: coin.id,
value: coin[type as keyof CoinWithFrequency] as string,
capToFrequency: coin.quotes[0].marketCap / coin.frequency[type],
type,
... options.withCoin ? {coin} : {}
}
})
phrases.push(...newPhrases)
}
return phrases
}
importowane tu opcje sortowania:
export interface SortOptions {
withCoin: boolean
}
sprowadzają się jedynie do określenia, czy chcemy widzieć wyniki z innymi danymi o coinie.
Do sortowania użyjemy funkcji:
import {SortOptions} from "../interface/SortOptions";
import fs from "fs";
import {convertCoinsToPhrases} from "./convertCoinsToPhrases";
export const sortCurrencies = async (options: SortOptions) => {
const coins = JSON.parse(fs.readFileSync(process.cwd() + '/out/coins-with-freq.json').toString());
const phrases = convertCoinsToPhrases(coins, options)
phrases.sort((a, b) => a.capToFrequency - b.capToFrequency)
return phrases;
}
stąd już prosta droga do zapisania wyników do pliku skryptem src/preparePhrases.ts
import fs from 'fs';
import {sortCurrencies} from "./helpers/sortCurrencies";
sortCurrencies({withCoin: false}).then((coins) => {
fs.writeFileSync(process.cwd() + '/out/phrases.json', JSON.stringify(coins));
console.log(coins);
}).catch(console.error)
Po jego włączeniu poleceniem:
ts-node src/preparePhrases.ts
Możemy zobaczyć, że dla bardzo mało znanych coinów, ale za to popularnych słów nasz współczynnik jest bardzo niski.
możemy się spodziewać wielu tweetów ze słowami takimi jak you
, giant
, spectrum
, pop
, cyl
, vote
, get
, real
czy kind
w których autor nie miał na myśli kryptowalut. Z drugiej strony nie istnieje obiektywne kryterium odcięcia.
Gdybym ustawił je na 100, wycięte zostało by 2328/16395 = 14% fraz. Przy wartości 5
mamy odcięcie 1560/16395 = 9.5%.
Podsumowanie
Obiektywne wyznaczenie kryterium odcięcia altcoinów z monitoringu okazało się niemożliwe, ale konieczność podjęcia kilku tysięcy decyzji typu "włączyć/wyłączyć" z obserwacji została zastąpiony jedną decyzją o granicznym stosunku wartości coina względem częstości użycia jego nazwy w języku angielskim.
Widzimy, że ogromna większość szumu jest wycinana jeśli zrezygnujemy z obserwacji około 10% altcoinów o nazwach lub skrótach będących popularnymi zwrotami.
Całość zamknęła się w około 211 liniach typescriptu, z czego 57 to interfejsy.