歩いたら休め

If the implementation is easy to explain, it may be a good idea.

【R】転職ドラフトのデータをスクレイピングして分析(集計)する

お久しぶりです。

最近上司と「機械学習とかその辺の技術が発展したら、真っ先に自動化されて仕事なくなるのはハンパなエンジニアと中間管理職だよね〜」という話をして危機感を募らせている @takeshi0406 です。

WEBエンジニアにはご存じの方も多いと思いますが、転職ドラフトというWEBサービスがあります。

job-draft.jp

このサービスは、次のような理念や問題感から始まった作られたものだそうです。素晴らしいです。

企業による公開競争入札
これなら、自由競争でのリアルな相対価値がわかるようになる。

エンジニアだからこそ、より明確に。

誰が評価され、誰が評価されないのか。
自分の価値向上には、これから何をすべきなのか。

私も「友達を紹介してオライリー・ジャパンの本をGETしよう!」の文言につられて、友だちを3人紹介した上で登録したのですが、レジュメを丁寧に読んでダメ出ししてもらえ(一度リジェクトされましたw)、その過程で自分のキャリアの振り返りができたので良かったと思っています。

また、何件か入札もいただいて、その中にプロジェクトで苦労した点に対して共感したという(私にとっては嬉しい)コメントもあり、サービスとして好印象を受けています。

ところが、実際使って同世代のエンジニアと話していると、以下のような不満や疑念を話すこともありました。

  • 同じようなスキルのレベルでも、結局年齢で入札額が決められてるんじゃないか(友人は「全く同じレジュメで20代中盤→30代中盤で出したら年収上がるんじゃねw」と冗談半分に話していました)
  • 著名なエンジニアと思しき人が、入札されていない or 低めの額で入札されている
  • 結局、入札額多い会社に目を付けられるかどうかで決まるんじゃないのか

もう一つ、

  • どんなスキルを持っている人が、高めの入札額がつけられるのか

という部分にも興味があるようでした。

というわけで、公開されているデータで分析できそうな、以下の2点について集計してみました。

  1. 年齢と最高提示額の関係
  2. どんなスキルを持っている人が、高めの入札額がつけられているか

今回は、書き捨てコードでデータの集計を行うものなので、統計用プログラミング言語Rを使いました。 おそらくエンジニアではPython + pandasを利用している方が多いと思いますが、個人的には、データの集計目的ではRのほうが楽に書けます。

スクレイピングでデータを取得する

スクレイピングでデータを取得します。以下のようなRのコードでcsvに保存しました。

関数の前にはroxygen形式のコメントを付けています。この辺もうまくまとめたRのプログラム開発については、以下の記事を読んでください。

qiita.com

library(rvest)
library(purrr)
library(dpylr)
library(stringr)
library(readr)

SLEEPTIME = 3 # 3秒ずつSleepさせる
MAX_PAGE_NUM = 29 # 手動で確認する
RANKING_BASE_URL = 'https://job-draft.jp/festivals/6/users?page='
PROFILE_BASE_URL = 'https://job-draft.jp/users/'

#' 今回の全ての参加者のデータをクロールして取得する
#' 
fetchAllData <- function() {
  rank_df <- fetchAllRankData()
  user_df <- rank_df$user_id %>% fetchAllUserData()
  return (
    dplyr::left_join(rank_df, user_df, by = 'user_id')
  )
}

#' ランキングページのデータを取得する
#' 
#' @return ユーザーIDのベクトル
fetchAllRankData <- function() {
  return (
    1:MAX_PAGE_NUM %>% 
      purrr::map(~fetchHtml(paste0(RANKING_BASE_URL, .))) %>% 
      purrr::map(parseRankData) %>% 
      purrr::reduce(rbind)
  )
}

#' 今回の参加者の全てのIDをクロールして取得する
#' 
#' @return ユーザーIDのベクトル
fetchAllUserData <- function(user_ids) {
  return (
    user_ids %>% 
      purrr::map(~list(., fetchHtml(paste0(PROFILE_BASE_URL, .)))) %>% 
      purrr::map(purrr::lift(parseUserData)) %>% 
      purrr::reduce(rbind)
  )
}

#' URLからHTMLを取得する
#' 
#' @param url ターゲットとなるurl
#' @return HTMLオブジェクト
fetchHtml <- function(url) {
  Sys.sleep(SLEEPTIME)
  return (
    xml2::read_html(url)
  )
}

#' ランキングページのHTMLをデータフレームに変換する
#' 
#' @param rank_html ランキングページのHTML
#' @return データフレーム
parseRankData <- function(rank_html) {
  return (
    data.frame(
      user_id = parseUserIds(rank_html),
      age_name = parseRoughAges(rank_html),
      value_name = parseMaxValues(rank_html)
    )
  )
}

#' ユーザーランキングのHTMLからユーザーIDのベクトルを取得する
#' 
#' @param rank_html ユーザーランキングのHTML
#' @return ユーザーIDのベクトル
parseUserIds <- function(rank_html) {
  return (
    rank_html %>% 
      rvest::html_nodes(xpath = '//div[@class="p-users-listview__item"]//a[@class="u-font-sl f-w-bold"]') %>% 
      rvest::html_attr('href') %>% 
      readr::parse_number() 
  )
}

#' おおまかな年齢(xx代後半など)を取り出す
#' 
#' @param rank_html ユーザーランキングのHTML
#' @return 年齢のベクトル
parseRoughAges <- function(rank_html) {
  return (
    rank_html %>% 
      rvest::html_nodes(xpath = '//div[@class="p-users-listview__item"]') %>% 
      rvest::html_text() %>% 
      stringr::str_extract('\\d0代(前半|中盤|後半)')
  )
}

#' おおまか最高指名額を取り出す
#' 
#' @param rank_html ユーザーランキングのHTML
#' @return 最高指名額のベクトル
parseMaxValues <- function(rank_html) {
  return (
    rank_html %>% 
      rvest::html_nodes(xpath = '//div[@class="p-users-listview__item"]') %>% 
      rvest::html_text() %>% 
      stringr::str_extract('(レジェンド|ゴッド|ウィザード|スター|プチリッチ|ノーマル|ノービス)級')
  )
}

#' ユーザーが持つスキルを取得する
#'
#' @param user_id ユーザーid
#' @param user_html ユーザーページのHTML
#' @return ユーザーについてのデータフレーム
parseUserData <- function(user_id, user_html) {
  return (
    data.frame(
      user_id = user_id,
      skills = parseUserSkills(user_html)
    )
  )
}

#' ユーザーが持つスキルを取得する
#' 
#' @param user_html ユーザーページのHTML
#' @return そのユーザーのスキルのベクトル
parseUserSkills <- function(user_html) {
  return (
    user_html %>% 
      rvest::html_nodes(xpath = '//li[@class="c-tag c-tag--s c-tag--gray c-tag--border-gray3 c-tag--s-rounded"]') %>% 
      rvest::html_text() %>% 
      stringr::str_trim()
  )
}

# users.csvに保存する
readr::write_csv(fetchAllData(), './users.csv')

以下のようなcsvが出来上がります。ログイン状態でスクレイピングを行えば、「○○級」より細かいデータが見られるのですが、他のユーザーの不利益になるおそれがあるのでやめました。

user_id,age_name,value_name,skills
xxxx,x0代前半,xxx級,Python
xxxx,x0代前半,xxx級,Ruby
yyyy,y0代中盤,yyy級,PHP
(以下略)

データの持ち方は、Hadley Wickham氏の提唱する整然データ(tidy data)を参考にしました。…といっても、DBの第一正規形のようなもので、「skillsカラムを一行に持たせず、ユーザーを複数行にしている」程度です。

id.fnshr.info

年齢と最高提示額の関係を集計する

まず、先程のスクレイピング結果のデータを読み込んで前処理する関数と、積み上げ棒グラフでプロットする関数を用意します。

library(dpylr)
library(stringr)
library(readr)
library(ggplot2)

AGE_DF <- readr::read_csv('./age.csv')
VALUE_DF <- readr::read_csv('./value.csv')

#' スクレイピングで取得したデータを読み込み、前処理を行う
#' ※ わざわざmax_incomeというカラムを追加しているのは、積み上げ棒グラフの順番を操作する方法が分からなかったからです
#' 
#' @return 年齢・最高額ごとに人数を集計したデータ
readAgeIncomeDf <- function() {
  return (
    readr::read_csv('./users.csv') %>%
      dplyr::distinct(user_id, value_name, age_rank, age_name) %>%
      dplyr::arrange(age_rank) %>%  
      dplyr::group_by(value_name, age_name) %>%
      dplyr::summarise(count = n()) %>% 
      dplyr::left_join(VALUE_DF, by = 'value_name') %>%
      dplyr::mutate(max_income = stringr::str_c(value_rank, '_', value_name))
  )
}

#' 年齢・最高額を積み上げ棒グラフでプロットする
#' 
#' @param df 元データ
plotAgeIncomeGrapth <- function(df) {
  ggplot2::ggplot(df, ggplot2::aes(x = age_name, y = count)) +
    ggplot2::geom_bar(stat="identity", aes(fill = max_income)) +
    ggplot2::scale_x_discrete(limits = rev(AGE_DF$age_name)) +
    ggplot2::theme(text = element_text(family = "HiraKakuPro-W3"))
}

また、AGE_DFとVALUE_DFの中身はこんな感じです。

> # Rのコンソール画面です
> AGE_DF %>% head
# A tibble: 6 × 2
  age_name age_rank
     <chr>    <int>
1 40代後半       12
2 40代中盤       11
3 40代前半       10
4 30代後半        9
5 30代中盤        8
6 30代前半        7
> VALUE_DF %>% head
# A tibble: 6 × 2
    value_name value_rank
         <chr>      <int>
1 レジェンド級          1
2     ゴッド級          2
3 ウィザード級          3
4     スター級          4
5 プチリッチ級          5
6   ノーマル級          6

まずは、すべての参加者のデータをプロットしてみましょう。

readAgeIncomeDf() %>% plotAgeIncomeGrapth()

f:id:takeshi0406:20170422185449p:plain

次に、高めの評価のところを見るために、「プチリッチ級」以上で集計してみます。

意外と山の形は、全員の場合と変わっていないように見えます。

readAgeIncomeDf() %>% dplyr::filter(value_rank %in% 1:5) %>% plotAgeIncomeGrapth()

f:id:takeshi0406:20170422190035p:plain

ところが、「ウィザード級」以上に注目すると、やはり30代以上が多くなります。

f:id:takeshi0406:20170422190217p:plain

ひとまず、今のところの傾向として

  • 20代はプチリッチ級ではよく入札されている
  • ウィザード級以上は30代以上でないと入札されづらい

ことは集計できました。

ただし、これが本当に年齢が低いと評価されづらいのか、年齢を経ることで本当にスキル(もしくはレジュメでの説得力)が上がっているのかは、さすがに情報量不足でわかりませんが。

また、今のところ高い額を提示しているのが、Speeeさんなどの数社しかないので、これからの期間で入札されて傾向が変わるのは充分考えられます。

どんなスキルを持っている人が、高めの入札額がつけられるのか

どんなスキルを身につければ(どんな技術が必要なプロジェクトを進めれば)、高い入札額の人たちの仲間入りできるかを考えてみましょう。

これには同様の集計を行った方がいますが、スター級以上しか集計していないために、「高い入札額を提示された人が持っているスキルなのか、それとも入札されていない人も持っているスキルなのか分からない」という問題があります。

qiita.com

つまり、「各入札額を特徴づけているスキル」を集計すればよく、これは自然言語処理でよく使われる指標であるTF-IDF(にあたるもの)を集計してあげれば良さそうです。

TF-IDFで文書内の単語の重み付け | takuti.me

『いくつかの文書があったとき、それぞれの文書を特徴付ける単語はどれだろう?』こんなときに使われるのがTF-IDFという値。

TFはTerm Frequencyで、それぞれの単語の文書内での出現頻度を表します。たくさん出てくる単語ほど重要!

IDFはInverse Document Frequencyで、それぞれの単語がいくつの文書内で共通して使われているかを表します。いくつもの文書で横断的に使われている単語はそんなに重要じゃない!

というわけで、TF-IDFを計算(データフレームに追加)する関数を用意します。…こういうのを自前で実装すると、プログラムのミスが怖いこと限りないですね。 もし見つけた方がいたら教えてください。

library(dpylr)
library(readr)

VALUE_DF <- readr::read_csv('./value.csv')

#' スクレイピングで取得したデータを読み込み、前処理を行う
#' 
#' @return スキル・最高額ごとに人数を集計したデータ
readSkillsIncomeDf <- function() {
  return (
    readr::read_csv('./users.csv') %>%
      dplyr::select(user_id, value_name, skills) %>% 
      dplyr::left_join(VALUE_DF, by = 'value_name') %>% 
      dplyr::group_by(value_rank, skills) %>%
      dplyr::summarise(count = n()) %>%
      dplyr::arrange(desc(count))
  )
}

#' TF-IDFを計算したカラムを追加する
#' 
#' @param df スキル・最高額ごとに人数を集計したデータ
#' @return TF-IDFの結果を追加したデータフレーム
mutateTfIdf <- function(df) {
  return (
    dplyr::inner_join(df, mutateTf(df), by = c('value_rank', 'skills')) %>% 
      dplyr::inner_join(mutateIdf(df), by = 'skills') %>% 
      dplyr::mutate(tfidf = tf * idf) %>% 
      dplyr::select(value_rank, skills, tfidf, count)
  )
}

#' TFを計算したカラムを追加する
#' 
#' @param df スキル・最高額ごとに人数を集計したデータ
#' @return TFの結果を追加したデータフレーム
mutateTf <- function(df) {
  sigma_df <- df %>% 
    dplyr::group_by(value_rank) %>% 
    dplyr::summarise(denominator = sum(count))
  return (
    df %>%
      dplyr::inner_join(sigma_df, by = 'value_rank') %>% 
      dplyr::mutate(tf = count / denominator) %>% 
      dplyr::select(value_rank, skills, tf)
  )
}

#' IDFを計算したカラムを追加する
#' 
#' @param df スキル・最高額ごとに人数を集計したデータ
#' @return IDFの結果を追加したデータフレーム
mutateIdf <- function(df) {
  N <- df %>% dplyr::distinct(value_rank) %>% nrow()
  return (
    df %>% 
      dplyr::group_by(skills) %>%
      dplyr::summarise(df = n()) %>% 
      dplyr::mutate(idf = log(N/df) + 1) %>% 
      dplyr::select(skills, idf)
  )
}

「レジェンド級」は現時点で1人しかいらっしゃらなかったので、「ゴッド級」から集計してみます。これも5人なので、有益な分析ができるかどうかはやや疑問ですが。

# ゴッド級 (value_rank == 2) のTF-IDF上位10件のスキルを表示する
readSkillsIncomeDf() %>%
  mutateTfIdf() %>%
  dplyr::filter(value_rank == 2) %>%
  dplyr::arrange(desc(tfidf)) %>% 
  head(10)

その結果がこちらです。

   value_rank               skills      tfidf count
        <int>                <chr>      <dbl> <int>
1           2                  AWS 0.05584600     3
2           2         DigitalOcean 0.04751468     1
3           2 elasticsearch-kibana 0.04751468     1
4           2            ExoPlayer 0.04751468     1
5           2                  kms 0.04751468     1
6           2              Posgres 0.04751468     1
7           2               Presto 0.04751468     1
8           2              reactjs 0.04751468     1
9           2       マネージメント 0.04751468     1
10          2                 採用 0.04751468     1

どうやら、他の方が書いていないスキルを持った方がいて、人数(count)が1人のスキルが上位に表示されているようです(ただし、この結果も有益だと思います)。「人数2人以上」の条件を足して集計し直しましょう。

# count >= 2の条件を足した集計
readSkillsIncomeDf() %>%
  mutateTfIdf() %>%
  dplyr::filter(value_rank == 2, count >= 2) %>%
  dplyr::arrange(desc(tfidf)) %>% 
  head(10)

こうなりました。

  value_rank        skills      tfidf count
       <int>         <chr>      <dbl> <int>
1          2           AWS 0.05584600     3
2          2     AngualrJS 0.04311201     2
3          2      CircleCI 0.03723067     2
4          2       fluentd 0.03723067     2
5          2          Java 0.03723067     2
6          2         nginx 0.03723067     2
7          2         Redis 0.03723067     2
8          2 Ruby on Rails 0.03723067     2
9          2          Ruby 0.03225806     2

同様に、ウィザード級、スター級、プチリッチ級、ノーマル級、未入札の結果も出しておきます。ただし、未入札の場合は「スキルが評価されていないケース」の他に、少なくとも「(WEB系の企業が多いために)スキルがマッチしていないケース」も含まれているように思います。

# ウィザード級に特有なスキル
   value_rank        skills      tfidf count
        <int>         <chr>      <dbl> <int>
1           3         MySQL 0.03394561    11
2           3    JavaScript 0.03216110     9
3           3       Jenkins 0.02468772     8
4           3           AWS 0.02160175     7
5           3          Java 0.02160175     7
6           3 Ruby on Rails 0.02160175     7
7           3      newrelic 0.02085048     5
8           3         Redis 0.01851579     6
9           3    capistrano 0.01786728     5
10          3       Datadog 0.01481790     3

# スター級に特有なスキル
   value_rank     skills      tfidf count
        <int>      <chr>      <dbl> <int>
1           4 JavaScript 0.02994896    16
2           4       Java 0.02424686    15
3           4      MySQL 0.02263041    14
4           4        PHP 0.01939749    12
5           4        AWS 0.01778103    11
6           4        C++ 0.01684629     9
7           4    Android 0.01497448     8
8           4     jQuery 0.01497448     8
9           4      React 0.01497448     8
10          4     docker 0.01454812     9

# プチリッチ級に特有な特有なスキル
   value_rank      skills      tfidf count
        <int>       <chr>      <dbl> <int>
1           5       MySQL 0.02870529    29
2           5  JavaScript 0.02865507    25
3           5        Java 0.02078659    21
4           5         PHP 0.01979675    20
5           5        Ruby 0.01886792    22
6           5         AWS 0.01682724    17
7           5     Jenkins 0.01583740    16
8           5 Objective-C 0.01490063    13
9           5      docker 0.01385773    14
10          5      Apache 0.01375443    12

# ノーマル級に特有なスキル
   value_rank     skills      tfidf count
        <int>      <chr>      <dbl> <int>
1           6 JavaScript 0.03572748    27
2           6      MySQL 0.03085353    27
3           6        PHP 0.02513992    22
4           6        Git 0.02381832    18
5           6     jQuery 0.02381832    18
6           6       Java 0.02056902    18
7           6        AWS 0.01942630    17
8           6    Jenkins 0.01714085    15
9           6     docker 0.01599813    14
10          6       Ruby 0.01584158    16

# 未入札に特有なスキル
   value_rank     skills      tfidf count
        <int>      <chr>      <dbl> <int>
1          NA      MySQL 0.02686086    62
2          NA JavaScript 0.02458226    49
3          NA       Java 0.02166199    50
4          NA     jQuery 0.02006715    40
5          NA        PHP 0.01819607    42
6          NA        AWS 0.01646311    38
7          NA     Apache 0.01555204    31
8          NA        Git 0.01454868    29
9          NA         C# 0.01354533    27
10         NA    Jenkins 0.01213071    28

いかがでしょうか?個人的には「がんばった割に大して面白い結果が出なかったな」という感想です。

入札上位の方のスキルセットを見る限り、ここで出ている技術がその人のコア技術である例は少ないように思います。

そもそも、「入札額が高いエンジニアに特有なスキルがある」という仮説自体がまちがっており、「それなりに評価されているエンジニアは、他にないスキルを持っている」もしくはもう少しエンジニアの区分をつけた上で分析すべき(例えばフロントエンドエンジニアとインフラエンジニアでは求められるスキルは全く違う)なのかもしれません。

または、「ざっくりと入札額の高いエンジニア」と「それ以外」くらいの粗い集計でも良かったのかもしれません。

まとめ

ひとまず、以下の2点は言えると思います。

  1. (今のところ)高い入札額を提示されているのは30代中盤
    • ただし、「年齢が低いと評価されづらい」のか、「年齢を経ることで本当にスキル(もしくはレジュメでの説得力)が上がる」のか、その他の原因なのかは分からない
  2. 「これを身につければ年収上がるべw」という技術は分からなかった

以上です。