---
title: "Introduction to tidycjk"
author: 'Youzhi Yu
University of Chicago'
bibliography: tidycjk.bib
output:
rmarkdown::html_vignette:
toc: true
toc_depth: 3
vignette: >
%\VignetteIndexEntry{Introduction to tidycjk}
%\VignetteEngine{knitr::rmarkdown}
%\VignetteEncoding{UTF-8}
---
```{r, include = FALSE}
knitr::opts_chunk$set(
collapse = TRUE,
message = FALSE,
warning = FALSE,
comment = "#>"
)
```
## Overview
Almost every text tool in R assumes that a word is a run of characters between
two spaces. That assumption is invisible until it fails, and it fails
completely on Chinese, Japanese and Korean, because Chinese and Japanese do not
put spaces between words at all. Hand `我今天很開心` to a whitespace tokeniser
and it returns one token: the whole sentence. Split it per character instead
and you get six, which cuts `開心` ("happy") in half and destroys the very
signal you were trying to measure.
The problem is not only segmentation. A CJK character occupies **two** terminal
columns but counts as **one** character, so `nchar()` and the width of a
printed table disagree, and every console table containing CJK text comes out
ragged. Fullwidth digits `123` look like digits and do not parse as a
number. A Japanese place name written only in Han characters cannot be told
from Chinese by script alone, and most libraries guess anyway.
**tidycjk** addresses these as separate, honest problems, each built on the
Unicode standard [@uax11; @uax14; @uax29] and on ICU, the Unicode
Consortium's own library, rather than on hand-written rules:
| Task | Function(s) |
|------|-------------|
| Detect | `has_cjk()`, `cjk_script()`, `cjk_detect_language()`, `cjk_ratio()` |
| Summarise a column | `cjk_summary()`, `cjk_char_counts()` |
| Segment | `cjk_segment()`, `cjk_tokens()`, `cjk_segmenters()`, `register_cjk_segmenter()` |
| Sentences, n-grams, cleaning | `cjk_sentences()`, `cjk_ngrams()`, `cjk_strip_punct()` |
| Order | `cjk_sort()`, `cjk_order()` |
| Measure width | `cjk_width()` |
| Lay out | `cjk_pad()`, `cjk_truncate()`, `cjk_wrap()` |
| Normalise | `to_halfwidth()`, `to_fullwidth()` (width), `cjk_normalize()` (Unicode forms) |
| Romanise | `cjk_romanize()` |
| Convert Han | `cjk_simplify()`, `cjk_traditionalize()` |
| Convert kana | `to_hiragana()`, `to_katakana()` |
| Hangul jamo | `cjk_jamo()`, `cjk_compose_jamo()` |
| Reference data | `cjk_blocks()` |
```{r setup}
library(tidycjk)
```
## Two layers, one contract
Most verbs work on a plain character vector, in the manner of **stringr**.
Three of them, `cjk_summary()`, `cjk_char_counts()` and `cjk_tokens()`, take
`(data, column)` instead and return a tibble, so they drop into a **dplyr**
pipeline without an adaptor [@wickham2014tidy]. Those three want a data
frame; they do not accept a bare character vector.
The contract is the same throughout, and it is worth stating once because the
rest of this vignette relies on it. Every vector-layer verb is vectorised:
`NA` in gives `NA` out, zero-length input gives zero-length output, and
nothing silently recycles a ragged pair: a mismatch is an error rather than
a warning and a short answer.
The tidy verbs answer about the column rather than about each row, so they
summarise rather than map. `cjk_char_counts()` and `cjk_tokens()` return no
rows for an empty column; `cjk_summary()` always returns exactly one, with
`NA` where there was nothing to average.
```{r corpus}
posts <- data.frame(
id = 1:5,
text = c(
"我今天很開心", # Chinese
"こんにちは、元気ですか", # Japanese, with kana
"안녕하세요", # Korean
"東京都", # Japanese written without kana
"no CJK here at all"
)
)
cjk_summary(posts, text)
```
`prop_with_cjk` answers "how many of these rows contain any CJK at all";
`mean_ratio` answers "how CJK are they". They come apart exactly where you
would want them to: a corpus of English with one stray ideograph per row
scores near 1 on the first and near 0 on the second.
```{r counts}
cjk_char_counts(posts, text)
```
## Detection, and the value of refusing to guess
`cjk_script()` reports the dominant script; `cjk_detect_language()` reports the
language.
```{r detect}
cjk_script(posts$text)
cjk_detect_language(posts$text)
```
Rows one, four and five come back `NA`. Row five is uninteresting: it has no
CJK in it at all. Rows one and four are the point. `東京都`
is Tokyo Metropolis, unambiguously Japanese, but it is written entirely in
Han characters, and **nothing in the script distinguishes it from Chinese**.
Japanese written without kana and Chinese are the same character set. Any
library that answers `"chinese"` there is guessing, and it will be wrong on
Japanese input in a way you cannot detect downstream, because the output looks
exactly like a confident correct answer.
`tidycjk` returns `NA`. If your corpus is known to be Chinese, ask for the
guess explicitly, so that the assumption lives in your script where a reader
can see it rather than in a library default where they cannot:
```{r detect-optin}
cjk_detect_language(posts$text, han_only = "chinese")
```
## Segmentation
Where a word ends is a fact about a language, not about Unicode, so it needs a
dictionary. `tidycjk` dispatches on an engine name rather than committing to
one, and two engines ship with the package.
`"icu"` is a real word segmenter, using the dictionary-based break iterators
that ICU carries [@icu] and **stringi** exposes [@gagolewski2022stringi]:
```{r segment-icu}
cjk_segment("我今天很開心", engine = "icu")
```
`我 / 今天 / 很 / 開心`: words, not characters. `"character"` is the
dictionary-free baseline, and the contrast shows what a segmenter is for:
```{r segment-character}
cjk_segment("我今天很開心", engine = "character")
```
In a pipeline, `cjk_tokens()` is the CJK-aware counterpart of **tidytext**'s
`unnest_tokens()` [@silge2016tidytext]:
```{r tokens}
cjk_tokens(posts[1:2, ], text, engine = "icu")
```
`vignette("segmentation", package = "tidycjk")` covers the engines, their
limits, and how to register **gibasa** [@gibasa] or jiebaR.
## Sentences and n-grams
Two preprocessing steps that generic tooling gets wrong. A regular expression
on `[.!?]` finds no sentence boundary at all in Chinese, because the
terminator is `。`:
```{r sentences}
cjk_sentences("我今天很開心。你呢?天氣很好!")
```
Character n-grams are the dictionary-free baseline for Chinese retrieval and
classification. Most words are one or two characters, so bigrams capture the
majority of them with no model at all, and they hold up on the names and
coinages where a segmenter is least reliable:
```{r ngrams}
cjk_ngrams("中文很好")
```
No gram is formed across whitespace, so nothing straddles two words of a
Latin run. Punctuation is a different matter, and `cjk_strip_punct()` is the
step that deals with it. It replaces each mark with a space rather than
deleting it, so a bigram cannot span the full stop between two sentences:
```{r strip-punct}
cjk_strip_punct("他說(今天)。真好")
cjk_ngrams(cjk_strip_punct("好。天")) # nothing spans the stop
cjk_ngrams(cjk_strip_punct("好。天", "")) # deleting it invents 好天
```
`gsub("[[:punct:]]", "", x)` is the spelling to avoid: R's default engine
resolves the class through the C library, so it removes nothing under
`LC_ALL=C`, and `perl = TRUE` removes nothing from CJK in any locale.
## Sorting
`sort()` reads `LC_COLLATE`, so the order it gives Han text is a property of
the session rather than of the data. These three surnames come out in code
point order under `C` and `en_US.UTF-8`, in pinyin order under `zh_CN.utf8`,
and in a third order under `ja_JP.utf8`. Each is defensible; none is
reproducible, and that is the trap: the same script sorts a name column
differently on a colleague's machine and says nothing about it.
The fallback order is worth knowing, because it is not arbitrary. Within the
base block the unified ideographs are arranged in KangXi radical-stroke
order, so code point order really is radical order. It is simply not
phonetic, and it stops being radical order across blocks, since every
Extension A character sorts before every base-block one, leaving a rare
character nowhere near its radical-mates.
Naming the collation is what makes the answer the same everywhere:
```{r sort}
x <- c("張", "王", "李") # Zhang, Wang, Li
x[order(vapply(x, utf8ToInt, integer(1)))] # code point order, explicitly
cjk_sort(x, locale = "zh") # pinyin
cjk_sort(x, locale = "zh-u-co-stroke") # stroke count
```
## Width, and why your tables are ragged
`nchar()` counts characters. A terminal counts columns, and Unicode Annex #11
assigns CJK characters two of them [@uax11].
```{r width}
labels <- c("中文", "abcd", "日本語")
nchar(labels)
cjk_width(labels)
```
Pad to a width rather than to a character count and the column lines up:
```{r pad}
cat(paste0("|", cjk_pad(labels, 8), "|"), sep = "\n")
```
Truncation and wrapping follow the same rule. Wrapping additionally respects
the Unicode line breaking algorithm [@uax14], so it will not put a break
before a closing bracket or a full stop:
```{r wrap}
cat(cjk_wrap("我今天很開心,因為天氣非常好而且朋友來看我", 12))
```
`vignette("width-and-layout", package = "tidycjk")` goes further.
## Normalisation and transliteration
`to_halfwidth()` narrows fullwidth forms and does **only** that, unlike an
`NFKC` pass, which also rewrites ligatures, Roman numerals and circled
numbers:
```{r halfwidth}
as.numeric(to_halfwidth("123"))
to_halfwidth("½ Ⅸ ①") # NFKC would rewrite all three
```
When the blunt instrument *is* the right one, for matching rather than display,
`cjk_normalize()` is it, and it also answers the case a reader may not expect:
plain `"nfc"` rewrites most of the CJK compatibility ideographs, because they
have singleton canonical mappings.
```{r normalize}
ga <- c("\u304c", "\u304b\u3099") # composed, then KA + voiced mark
nchar(ga)
nchar(cjk_normalize(ga))
cjk_normalize("AB ①", form = "nfkc")
```
Romanisation, Han script conversion, kana conversion and Hangul jamo
decomposition are all available on the same contract:
```{r translit}
cjk_romanize("中文")
cjk_simplify("漢字")
to_hiragana("カタカナ")
cjk_jamo("한")
```
Each has limits worth knowing before you trust it. Romanisation reads each
Han character on its own, so a word like `银行` ("bank") gets the wrong
reading; simplified/traditional conversion resolves characters from context
but does not substitute regional vocabulary.
`vignette("transliteration", package = "tidycjk")` covers each.
## What tidycjk is built on
`cjk_blocks()` exports the block table the whole package rests on, so the
definition of "CJK" can be read rather than guessed at:
```{r blocks}
head(cjk_blocks(), 8)
nrow(cjk_blocks())
```
Nothing here makes a network request, and the package needs no compiled code
of its own.
## References