Rいろいろ

R
Rで書いたいろいろなコードから、メモを残したほうがよさげなものをよりぬき。
公開

2025年9月13日

1 コードを書く

library(tidyverse)

# tidyverse系命名規則
# 1. 関数・オブジェクト・列名: snake_case
#  例: read_data(), daily_sales, customer_id, is_active
# 2. S3メソッド: generic.class
#  例: print.my_model, predict.my_model
# 3. クラス名(S4/R6): CamelCase
#  例: LinearModel, DataLoader
# 4. 定数(ほぼ使わない): ALL_CAPS
# 
# コツ
# 1. ブールは接頭辞:is_, has_, can_, should_(例: is_valid, has_missing)
# 2. 単位や型は接尾辞:_pct, _ms, _id, _dt, _usd
# 3. 関数名は動詞から:load_data(), fit_model(), plot_residuals()
# 4. 略語は最小限に:num_customers はOK、ncst はNG

tidyverse_frequently_used <- 
  iris %>%
  # filter()関数。引数を識別しやすいように改行。
  dplyr::filter(
    Species == "setosa"
    ) %>% 
  # 短いコードなら改行しなくても構わない。
  dplyr::select(Species, Sepal.Length, Petal.Length) %>% 
  # ライブラリ名は宣言したほうがトラブルが少ない。
  # たとえライブラリをロードした後としても。
  dplyr::mutate(ratio = Sepal.Length / Petal.Length) %>% 
  dplyr::group_by(Species) %>% 
  dplyr::summarise(
    mean_ratio = mean(ratio, na.rm = TRUE)
    )

2 数多くシートを含むMSExcelファイルをたっぷり読み込んで結合してTable 1をつくる

賃金構造基本統計調査(都道府県パネル)について、データを読み込んでTable 1と相関行列とをつくって、多重共線性評価までやるRコード。重みは従業者数

再現性に関する方針は、以下に示す通り。

+ 列名は `janitor::clean_names()` 後の **snake_case**を用いる(例:`N_of_employees` → `n_of_employees`)。  
+ 欠損・ゼロは明示的に扱う(例:`filter(n_of_employees > 0)`)。  
+ .featherにて保存・読込。.rdsや.csvなど、これまで用いたファイル形式に比べて作業速いから。

Rコードをすべて列記すると、読むだけで疲れてしまう。小職がひっかかったところだけ、抜粋して解説する。完全なコードは、Githubにおいてある。それぞれ、 データ作成Table 1など記述的処理をみてね。

2.1 データを作る

読み取るべき変数を指定する。もともとMSExcel(.csvだけど)ファイルで、変数は横に並んでいる。この並んでいる各変数を書き忘れるから、原本にあたって漏れなく書き出す。

items <- 
  c(
    "年齢",
    "勤続年数",
    "所定内実労働時間数",
    "超過実労働時間数",
    "きまって支給する現金給与額",
    "所定内給与額",
    "年間賞与その他特別給与額",
    "労働者数"
)

stringr()を用いて、必要がない記号を削除する。

本Rコードは、年次をMSExcelファイルを収納するフォルダ名から、都道府県名と産業名とをMSExcelシート名から頂戴する。ファイル名は、バッチ処理するときに読み取りファイルリストとしてのみ使う。

いま、ファイル名は(1-8-1-1)2010pref_01-02.xlsxで、また同ファイルに含まれるシート群には01-04北海道E製造業があるとしよう。シート名については、「北海道」と「製造業」だけが必要な情報である。言い換えれば、シート名を構成する英数字と記号は不要で、かつ都道府県名と産業名とを切り分けられるならば、自動的に同情報を引っ張り出せる。

というわけで、不要な文字列を削除する関数。stringr::str_replace_all()がいい仕事をする。以下はいい仕事をするために必要な正規表現。

  • []:角括弧内にあるいずれかの記号
  • A-Za-z0-9:アルファベット大文字小文字AからZまでと数字。[:digit:][:alpha:]でもよさげ。
  • [:punct:]:記号類。
  • \\s:空白。
clean_sheet_name <- 
  function(x) {
    x %>%
      stringi::stri_trans_nfkc() %>%
      stringr::str_replace_all(
        "[A-Za-z0-9[:punct:]\\s]+", 
        ""
        ) %>% 
      stringr::str_squish()
  }

シートを読む関数。

元データがもつ性質を利用する。すなわち、

  • 読み込む範囲を指定する:賃金センサスは、対象全ファイルにおいて、必要な数値はすべてD12:AI50に必ず入っている。これ以外は読まなくてもよい。行名や列名は後から与えればよい。やはり同じだから。
  • 形推定を省略させる:これが意外に計算資源を消費する。型は後からどうせ指定し直すから、さしあたりはテキストとして読み込む。
  • エラー避けをいくつか仕込む。
# A function reading a sheet
read_one_sheet <- function(file, sheet) {
  year <- extract_year(file)
  # Fix reading ranges of target cell
  rng  <- "D12:AI50"
  # Read data
  # If failed, the process is skipped.
  # (No skips were there)
  raw <- 
    tryCatch(
      readxl::read_excel(
        file, 
        sheet = sheet, 
        range = rng, 
        col_names = FALSE,
        col_types = "text"
        ),
      error = function(e) tibble()
      )
}

先ほどつくったread_onesheet関数を繰り返して、多数あるファイルに含まれる多数シートを読み込む関する。

furrr::future_シリーズを、全CPU利用して走らせるようplan()にて設定する。multisessionにてたいていは問題ない。

次に、指定したフォルダ(ディレクトリ)に含まれるMSExcelファイルを探して名前を取得する。OSが変わったり、文字コード毎にいろいろ面倒が起きた時の例外処理を考えると、3つに分けたほうがいい。まったく、OS毎になんでこんなに挙動が異なるやら。もう少しなんとかしてほしい。これがあるから、WSLはありがたいともおもえるし、計算用にLinuxマシンを1台用意したくなる。

以下は、それぞれがもっている機能。

  • fs::dir_ls(..., glob = "**/*.{xls,xlsx,XLS,XLSX,xlsm,XLSM}"):シェルっぽいワイルドカードとglobを使う。書きやすくて速い。主にfs::由来。fs::はC++だから。 読みやすい・書きやすい(拡張子の列挙が楽)。ただし大文字小文字はOS毎に扱いが異なることから、同じような内容を列記するハメになる。

  • fs::dir_ls(..., regexp = "\\.(xls|xlsx|xlsm)$"):正規表現にてマッチ。1パターンで拡張子を表現できる。

  • base::list.files(..., pattern="\\.(xls|xlsx|xlsm)$", recursive=TRUE, full.names=TRUE, ignore.case=TRUE):やはり正規表現。 ignore.case=TRUEを使えるから、大文字小文字に由来する問題を回避できる。baseパッケージだからたいてい動作する。

そしてfurrr::future_map_dfr()。これがあるおかげで速い。CPUがリストにあるファイル群から手当たり次第にシートを読みまくる。おかげで計算中はCPU利用率は100%になる。お部屋を涼しくしましょう。360ファイル36,000シートでだいたい1時間弱かかる。もうひとつ入れ子になっている方もfurrr::にする手もあるが、シートまで手当たり次第に読みまくることになり、収拾がつかなくなるたぶん。

# strategy
plan(multisession, workers = parallel::detectCores() - 1)
# 
read_all <- 
  function(
    data_dir = "data",
    save_per_sheet = TRUE,
    out_dir = "out_sheets",
    feather_only = TRUE
    ) 
    {
    data_dir <- fs::path_expand(data_dir)
    if (!fs::dir_exists(data_dir)) stop("フォルダが見つかりません: ",
                                        data_dir)
    files_fs_glob <- fs::dir_ls(
      data_dir, recurse = TRUE, type = "file",
      glob = "**/*.{xls,xlsx,XLS,XLSX,xlsm,XLSM}"
      )
    files_fs_re <- fs::dir_ls(
      data_dir, recurse = TRUE, type = "file",
      regexp = "\\.(xls|xlsx|xlsm)$"
      )
    files_base <- list.files(
      data_dir, pattern="\\.(xls|xlsx|xlsm)$",
      recursive=TRUE, full.names=TRUE, ignore.case=TRUE
      )
  files <- unique(c(files_fs_glob, files_fs_re, files_base))
  message("Excelファイル数: ", length(files))
  if (length(files) == 0) return(tibble())
  # 必殺入れ子map
  furrr::future_map_dfr(
    files,
    function(f) {
      sh <- readxl::excel_sheets(f)
      # 内側は通常の map_dfr(逐次)
      map_dfr(seq_along(sh), function(j) {
        s <- sh[j]
        d <- read_one_sheet(f, s)
        if (save_per_sheet) save_one_sheet(
          d, 
          j, 
          out_dir = out_dir, 
          feather_only = feather_only
          )
        d
        }
        )
    },
    .options = furrr::furrr_options(scheduling = 1) 
  )
}
# 

2.2 Table 1

毎度おなじみTable 1。dplyr::summarise()で作って整形してもいいけれど、ここはgtsummaryライブラリにお願いしましょう。例のごとく、必要な関数群を作ってから、ぶん回す。

まずは労働者数合計。特に物珍しいところはない。

# Helper: one-row total employees by gender (within a size block)
make_block_total_employees <- 
  function(svy_data) {
    df <- svy_data$variables %>% dplyr::as_tibble()
    if (!"gender" %in% names(df)) df <- df %>% dplyr::mutate(gender =factor("(Missing)"))
  gtsummary::tbl_custom_summary(
    data    = df, 
    by = gender, 
    include = n_of_employees,
    stat_fns = everything() ~ (function(x) sum(x, na.rm = TRUE)),
    statistic = everything() ~ "{stat}",
    label   = list(n_of_employees ~ "Employees [total]")
  ) %>%
    gtsummary::add_overall(last = TRUE) %>%
    gtsummary::modify_header(all_stat_cols() ~ "**{level}**") %>%
    gtsummary::bold_labels() %>%
    gtsummary::modify_fmt_fun(everything() ~ scales::comma)
}

次は連続量部分。使う変数選んで、名前(ラベル)を与えて、男女毎に平均(標準偏差)計算する。ポイントは、statistic = list(gtsummary::all_continuous() ~ "{mean} ({sd})")digits = list(gtsummary::all_continuous() ~ 1)。前者は連続量すべてについて平均と標準偏差を計算させる。後者は有効数字桁数指定。たくさん指定したくなるが、表が横長になるだけであまり意味はない。

まぁなんというか、コード長いな。とはいえ、いままでさわった言語ではもっとも書きやすい。Perlはなんだったんだろう。あれは若いうちでないとできない。

# Helper: one-row total employees by gender (within a size block)

# Helper: continuous block (weighted means with SD)
make_block_cont <- function(svy_data) {
  vars_cont <- 
    c(
      "mean_age","length_of_service",
      "actual_number_of_scheduled_hours_worked",
      "actual_number_of_overtime_worked",
      "contractual_cash_earnings",
      "hourly_scheduled_cash_earnings",
      "annual_special_cash_earnings"
    ) %>% 
    intersect(names(svy_data$variables))
  labels_cont <- 
    list(
      mean_age ~ "Mean age [years]",
      length_of_service ~ "Length of service [years]",
      actual_number_of_scheduled_hours_worked ~ "Scheduled hours worked [h/mo]",
      actual_number_of_overtime_worked        ~ "Overtime hours [h/mo]",
      contractual_cash_earnings               ~ "Contractual cash earnings [k JPY/mo]",
      hourly_scheduled_cash_earnings          ~ "Hourly scheduled cash earnings [JPY/h]",
      annual_special_cash_earnings            ~ "Annual special cash earnings [k JPY/yr]"
      )
  gtsummary::tbl_svysummary(
    data = svy_data, 
    by = gender, 
    include = dplyr::all_of(vars_cont),
    statistic = list(gtsummary::all_continuous() ~ "{mean} ({sd})"),
    digits    = list(gtsummary::all_continuous() ~ 1),
    missing   = "ifany",
    label     = labels_cont
    ) %>% 
    gtsummary::add_overall(last = TRUE) %>%
    gtsummary::bold_labels()
}

いよいよTable 1生成。gtsummary::tbl_stack()で上から積み上げられるあたりがすばらしい。gtsummary::デフォルトだけでこれをやろうとすると、ちょっと大変。

# Helper: one-row total employees by gender (within a size block)

# Assemble the table: spanners = company_size, columns = gender (Female/Male/Overall)
size_levels <- 
  svy2$variables$company_size %>% 
  droplevels() %>% 
  levels()
block_list <- size_levels %>%
  purrr::map(function(sz) {
    svy_sz <- svy2 %>% dplyr::filter(company_size == sz)
    list(
      make_block_total_employees(svy_sz),
      make_block_cont(svy_sz),
      make_block_cat(svy_sz, "age_class_top", "Age class (top)"),
      make_block_cat(svy_sz, "industry_top",  "Industry (top)")
    ) %>% 
      gtsummary::tbl_stack() %>%
      gtsummary::modify_header(all_stat_cols() ~ "**{level}**")
    }
    )

table1a <- gtsummary::tbl_merge(tbls = block_list,
                                tab_spanner = paste0("**", size_levels, "**"))

2.3 相関行列

Table 1とセットにする相関行列。連続量がいくつかあるなら、モデリング前に必ずつくる。後にあらわれる多重共線性を評価しないといけないから。多重共線性評価も重要だけれど、実は相関行列を眺めて、相関が高い変数同士は、あたりまえだけれど多重共線性が発生するもとになる。

ちなみに、やはり賃金同士に強い相関がある。1つだけ残してあとは除去してVIFを計算すると、十分に多重共線性を避けられそうなことがわかった。

今回は、労働者数で重み付けした。

Rコードには特に目新しいことはない。

vars_cont <- c(
  "mean_age","length_of_service",
  "actual_number_of_scheduled_hours_worked",
  "actual_number_of_overtime_worked",
  "contractual_cash_earnings",
  "hourly_scheduled_cash_earnings",
  "annual_special_cash_earnings"
  ) %>% 
  intersect(names(wc))

# Weighted Pearson
form <- 
  as.formula(paste("~", paste(vars_cont, collapse = " + ")))
cov_mat <- 
  survey::svyvar(
    form, design = svy, na.rm = TRUE
    ) %>% 
  as.matrix()
cor_w   <- 
  cov2cor(cov_mat) %>% round(3)

cor_w %>%
  as.data.frame() %>% 
  tibble::rownames_to_column("Variable") %>%
  gt::gt() %>% 
  gt::tab_caption("Table S1. Employee-weighted Pearson correlation matrix")

# Save CSV for appendix
readr::write_csv(as_tibble(cor_w, rownames = "variable"), "correlation_weighted_pearson.csv")

3 写真から位置情報を引っこ抜いて地図に投影する

タイにある工業団地にて集めた求人票写真がある。

たくさんあるが、どこで撮影したかは引用しないと。企業がある場所を記録することにもなる。 広すぎるから数日かけて回る

ということで、画像から位置情報を抜き取る。昔はtcl/tkを使って書いた記憶があるけれど、いまならexifr()という便利なライブラリがある。その他ライブラリを含めて、読み込み部分コードは省略。

はじめに、画像データがある場所をRに教える。here::here()を使うと、子ディレクトリ内も巡回してくれて便利。引数patternには、一通り画像ファイル拡張子を教える。もっとも、jpgしかないから、いらないかもしれない。(?i)を付して、大文字小文字を区別しないようにする。画像ファイル拡張子は.jpgだったり.JPGだったりしていまひとつ統一されない事がある一方、システムはしっかり大文字小文字を区別する。なので、区別しないでね、と教えることはとっても大事。

exif引数名候補もここで与える。たいていはGPSLatitudeGPSLongitudeとがあれば足りるが、スマホ機種次第で多少違ったりする。なので、複数候補を与える。

このプロセスは、少々時間がかかる。画像1,500枚で数分程度。

# 
## ---- load.data ----
imgs <- list.files(
  path = here::here("img"),
  recursive = TRUE, full.names = TRUE,
  pattern = "(?i)\\.(jpe?g|heic|heif|png|tif?f|webp)$"
)
# obtain exif data from images
img_metadata <- 
  exifr::read_exif(imgs)
# 
## ---- candidates.variables ----
candidate_lat <- c("GPSLatitude", "Composite.GPSLatitude", "EXIF.GPSLatitude")
candidate_lon <- c("GPSLongitude","Composite.GPSLongitude","EXIF.GPSLongitude")
candidate_dt  <- c("DateTimeOriginal", "CreateDate", "ModifyDate", "FileModifyDate")
# 

画像は、一度exif情報を取得後は削除する。特にOnedriveなど使う場合、容量制限があるから注意する。

次はお助け関数集。

exifが60進法でもつ緯度経度を10進法に変換する関数、時刻フォーマットを整えつつタイムゾーンをBKKにする関数、緯度経度が入っているであろう変数をもれなく抽出する関数。

# 
## ---- helper.function ----
# 60deg. → decimal
dms_to_decimal <- function(x) {
  if (is.numeric(x)) return(x)
  if (is.na(x)) return(NA_real_)
  s <- tolower(x)
  nums <- 
    # ここがポイント。
    # 数字部分のみを取り出す。
    stringr::str_match(s, "([0-9\\.]+)\\D+([0-9\\.]+)?\\D*([0-9\\.]+)?")
  # hhmmssを取り出す。
  # この時点ではまだ60進法
  deg <- 
    as.numeric(nums[,2]); min <- as.numeric(nums[,3]); sec <- as.numeric(nums[,4])
  deg[is.na(deg)] <- 0; min[is.na(min)] <- 0; sec[is.na(sec)] <- 0
  # 60進法から10進法へと変換
  val <- deg + min/60 + sec/3600
  # おまじない
  if (str_detect(s, "[sw]")) val <- -val
  val
}
# day 
parse_dt_tidy <- 
  function(x, tz_out = "Asia/Bangkok") {
    x <- as.character(x)
    x <- 
      x %>% 
      stringr::str_replace_all("^([0-9]{4}):", "\\1-") %>%
      stringr::str_replace_all(
        "^([0-9]{4}-[0-9]{2}):", 
        "\\1-"
        )
    dt <- suppressWarnings(
      lubridate::parse_date_time(
        x, 
        orders = c("Y-m-d H:M:S", "Y/m/d H:M:S", "Ymd HMS"),
        tz = "Asia/Bangkok"
        )
      )
    with_tz(dt, tzone = tz_out)
  }
# make a complete candidates' list
coalesce_from <- 
  function(.df, candidates) {
    cols <- 
      # choose any of existing candidates (GPSLattitude...ect.) from .df
      # and make a tibble data frame
      dplyr::pick(
        .df, 
        dplyr::any_of(candidates)
        )
    # When no candidates were there, return NA
    # Otherwise, dplyr::coalesce() returns errors.
    if (ncol(cols) == 0) return(NA)  
    # find the first non-NA element
    # !!! is a splice operator splitting vector / list as argument
    # coalesce(c(1,NA,3), c(10,20,NA)) -> c(1,20,3)
    # (tibble's style is LIST. Errors will be returned if not be split)
    dplyr::coalesce(!!!cols)
}

dplyr::coalesce()は、ちょっとわかりにくい。日頃使うところもないし。以下がわかりやすいかも、と書いておいてなんだか実はよくわからん。

library(tidyverse)
# おためしデータ
df <- 
  dplyr::tibble(
    prefecture   = c("長崎", NA,      NA),
    PREF_NAME    = c(NA,     "佐賀",   NA),
    `都道府県名` = c(NA,     NA,      "福岡")
    )
# 都道府県名が入った変数名候補
std_pref <- c("prefecture","Pref","PREF_NAME","都道府県名")
# ふさわしい都道府県名を入れた新しい変数を追加する。 
df %>%
  # 変数を1つ追加する
  dplyr::mutate(
    pref_std = {
      # 中間変数(?)としてとりあえずac。
      # std_prefに入ってる都道府県名中いずれかにあてはまる変数を抽出。
      ac <- dplyr::across(
        dplyr::any_of(std_pref)
        )
      # そんな変数なければNA
      if (ncol(ac) == 0) NA_character_ 
      # そんな変数あればdplyr::coalesce()にて
      # pref_std内容になる観測値を返す。
      # 内容は、ac中std_prefにあてはまる変数について、最初にあらわれる都道府県名。
      # purrr::reduce()を使って、最低限の列(変数)のみ残るようにする。
      else purrr::reduce(
        ac, 
        dplyr::coalesce
        )
    }
  )

おつぎは本番。必要なところろ抜き出す。またもやcoalesce()を頻用しまくり。だって便利なんですもの。

# 
## ---- refine.data ----
img_metadata_refined <-
  img_metadata %>%
  dplyr::mutate(
    lat_raw = {
      ac_df <- across(any_of(candidate_lat))
      if (ncol(ac_df) == 0) NA else do.call(dplyr::coalesce, ac_df)
    },
    lon_raw = {
      ac_df <- across(any_of(candidate_lon))
      if (ncol(ac_df) == 0) NA else do.call(dplyr::coalesce, ac_df)
    },
    dt_raw = {
      ac_df <- across(any_of(candidate_dt))
      if (ncol(ac_df) == 0) NA else do.call(dplyr::coalesce, ac_df)
    }
  ) %>%
  dplyr::mutate(
    latitude  = suppressWarnings(as.numeric(lat_raw)),
    longitude = suppressWarnings(as.numeric(lon_raw))
  ) %>%
  dplyr::mutate(
    latitude  = ifelse(is.na(latitude),  dms_to_decimal(lat_raw), latitude),
    longitude = ifelse(is.na(longitude), dms_to_decimal(lon_raw), longitude)
  ) %>%
  dplyr::mutate(
    taken_at_bkk = parse_dt_tidy(dt_raw, tz_out = "Asia/Bangkok")
  ) %>%
  dplyr::transmute(
    source_file = dplyr::coalesce(SourceFile, FileName),
    latitude, longitude,
    altitude = GPSAltitude,
    taken_at_bkk
  ) %>%
  dplyr::filter(!is.na(latitude), !is.na(longitude)) %>%
  sf::st_as_sf(coords = c("longitude", "latitude"), crs = 4326, remove = FALSE)
# save
readr::write_excel_csv(
  sf::st_drop_geometry(img_metadata_refined), 
  "img_metadata.csv"
  )