お久しぶりです。
最近上司と「機械学習とかその辺の技術が発展したら、真っ先に自動化されて仕事なくなるのはハンパなエンジニアと中間管理職だよね〜」という話をして危機感を募らせている @takeshi0406 です。
WEBエンジニアにはご存じの方も多いと思いますが、転職ドラフトというWEBサービスがあります。
このサービスは、次のような理念や問題感から始まった作られたものだそうです。素晴らしいです。
企業による公開競争入札。
これなら、自由競争でのリアルな相対価値がわかるようになる。
エンジニアだからこそ、より明確に。誰が評価され、誰が評価されないのか。
自分の価値向上には、これから何をすべきなのか。
私も「友達を紹介してオライリー・ジャパンの本をGETしよう!」の文言につられて、友だちを3人紹介した上で登録したのですが、レジュメを丁寧に読んでダメ出ししてもらえ(一度リジェクトされましたw)、その過程で自分のキャリアの振り返りができたので良かったと思っています。
また、何件か入札もいただいて、その中にプロジェクトで苦労した点に対して共感したという(私にとっては嬉しい)コメントもあり、サービスとして好印象を受けています。
ところが、実際使って同世代のエンジニアと話していると、以下のような不満や疑念を話すこともありました。
- 同じようなスキルのレベルでも、結局年齢で入札額が決められてるんじゃないか(友人は「全く同じレジュメで20代中盤→30代中盤で出したら年収上がるんじゃねw」と冗談半分に話していました)
- 著名なエンジニアと思しき人が、入札されていない or 低めの額で入札されている
- 結局、入札額多い会社に目を付けられるかどうかで決まるんじゃないのか
もう一つ、
- どんなスキルを持っている人が、高めの入札額がつけられるのか
という部分にも興味があるようでした。
というわけで、公開されているデータで分析できそうな、以下の2点について集計してみました。
- 年齢と最高提示額の関係
- どんなスキルを持っている人が、高めの入札額がつけられているか
今回は、書き捨てコードでデータの集計を行うものなので、統計用プログラミング言語のRを使いました。 おそらくエンジニアではPython + pandasを利用している方が多いと思いますが、個人的には、データの集計目的ではRのほうが楽に書けます。
スクレイピングでデータを取得する
スクレイピングでデータを取得します。以下のようなRのコードでcsvに保存しました。
関数の前にはroxygen形式のコメントを付けています。この辺もうまくまとめたRのプログラム開発については、以下の記事を読んでください。
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カラムを一行に持たせず、ユーザーを複数行にしている」程度です。
年齢と最高提示額の関係を集計する
まず、先程のスクレイピング結果のデータを読み込んで前処理する関数と、積み上げ棒グラフでプロットする関数を用意します。
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()
次に、高めの評価のところを見るために、「プチリッチ級」以上で集計してみます。
意外と山の形は、全員の場合と変わっていないように見えます。
readAgeIncomeDf() %>% dplyr::filter(value_rank %in% 1:5) %>% plotAgeIncomeGrapth()
ところが、「ウィザード級」以上に注目すると、やはり30代以上が多くなります。
ひとまず、今のところの傾向として
- 20代はプチリッチ級ではよく入札されている
- ウィザード級以上は30代以上でないと入札されづらい
ことは集計できました。
ただし、これが本当に年齢が低いと評価されづらいのか、年齢を経ることで本当にスキル(もしくはレジュメでの説得力)が上がっているのかは、さすがに情報量不足でわかりませんが。
また、今のところ高い額を提示しているのが、Speeeさんなどの数社しかないので、これからの期間で入札されて傾向が変わるのは充分考えられます。
どんなスキルを持っている人が、高めの入札額がつけられるのか
どんなスキルを身につければ(どんな技術が必要なプロジェクトを進めれば)、高い入札額の人たちの仲間入りできるかを考えてみましょう。
これには同様の集計を行った方がいますが、スター級以上しか集計していないために、「高い入札額を提示された人が持っているスキルなのか、それとも入札されていない人も持っているスキルなのか分からない」という問題があります。
つまり、「各入札額を特徴づけているスキル」を集計すればよく、これは自然言語処理でよく使われる指標である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点は言えると思います。
- (今のところ)高い入札額を提示されているのは30代中盤
- ただし、「年齢が低いと評価されづらい」のか、「年齢を経ることで本当にスキル(もしくはレジュメでの説得力)が上がる」のか、その他の原因なのかは分からない
- 「これを身につければ年収上がるべw」という技術は分からなかった
以上です。