{"action":"created","comment":{"url":"https://api.github.com/repos/stripe/brushfire/pulls/comments/26232310","id":26232310,"diff_hunk":"@@ -0,0 +1,226 @@\n+package com.stripe.brushfire\n+package spark\n+\n+import com.twitter.algebird._\n+import com.twitter.algebird.Operators._\n+import com.twitter.bijection.Injection\n+\n+import org.apache.spark.rdd.RDD\n+import org.apache.spark.SparkContext\n+import org.apache.spark.SparkContext._\n+\n+import java.util.Random\n+\n+import scala.reflect.{ classTag, ClassTag }\n+\n+case class Trainer[K: Ordering, V, T: Monoid: ClassTag](\n+    trainingData: RDD[Instance[K, V, T]],\n+    sampler: Sampler[K],\n+    trees: RDD[(Int, Tree[K, V, T])]) {\n+  import Trainer.ExtraRDDOps\n+\n+  private def context: SparkContext = trainingData.context\n+\n+  def saveAsTextFile(path: String)(implicit inj: Injection[Tree[K, V, T], String]): Trainer[K, V, T] = {\n+    trees\n+      .map {\n+        case (i, tree) =>\n+          s\"$i\\t${inj(tree)}\"\n+      }\n+      .saveAsTextFile(path)\n+    this\n+  }\n+\n+  /**\n+   * Update the leaves of the current trees from the training set.\n+   *\n+   * The leaves target distributions will be set to the summed distributions of the instances\n+   * in the training set that would get classified to them. Often used to initialize an empty tree.\n+   */\n+  def updateTargets: Trainer[K, V, T] = {\n+    type LeafId = (Int, Int)\n+\n+    val treeMap: scala.collection.Map[Int, Tree[K, V, T]] = trees.collectAsMap()\n+\n+    val collectLeaves: Instance[K, V, T] => Iterable[(LeafId, T)] = { instance =>\n+      for {\n+        (treeIndex, tree) <- treeMap.toList\n+        repetition = sampler.timesInTrainingSet(instance.id, instance.timestamp, treeIndex)\n+        i <- 1 to repetition\n+        leafIndex <- tree.leafIndexFor(instance.features).toList\n+      } yield {\n+        (treeIndex, leafIndex) -> instance.target\n+      }\n+    }\n+\n+    val newTrees = trainingData\n+      .flatMap(collectLeaves)\n+      .sumByKey\n+      .map {\n+        case ((treeIndex, leafIndex), target) =>\n+          treeIndex -> Map(leafIndex -> target)\n+      }\n+      .sumByKey\n+      .map {\n+        case (treeIndex, leafTargets) =>\n+          val newTree =\n+            treeMap(treeIndex).updateByLeafIndex { index =>\n+              leafTargets.get(index).map(LeafNode(index, _))\n+            }\n+\n+          treeIndex -> newTree\n+      }\n+      .cache()\n+\n+    copy(trees = newTrees)\n+  }\n+\n+  def expandInMemory(times: Int)(implicit evaluator: Evaluator[V, T], splitter: Splitter[V, T], stopper: Stopper[T]): Trainer[K, V, T] = {\n+    type LeafId = (Int, Int)\n+\n+    val treeMap: scala.collection.Map[Int, Tree[K, V, T]] = trees.collectAsMap()\n+    val rng: Random = new Random(1234L)\n+\n+    val sampleInstances: Instance[K, V, T] => Iterable[(LeafId, Instance[K, V, T])] = { instance =>\n+      for {\n+        (treeIndex, tree) <- treeMap.toList\n+        repetition = sampler.timesInTrainingSet(instance.id, instance.timestamp, treeIndex)\n+        i <- 1 to repetition\n+        leaf <- tree.leafFor(instance.features).toList\n+        if stopper.shouldSplit(leaf.target) && rng.nextDouble < stopper.samplingRateToSplitLocally(leaf.target)\n+      } yield {\n+        (treeIndex, leaf.index) -> instance\n+      }\n+    }\n+\n+    val emptyExpansions = context\n+      .parallelize(0 until sampler.numTrees)\n+      .map { _ -> List[(Int, Node[K, V, T])]() }\n+\n+    val newTrees = trainingData\n+      .flatMap(sampleInstances)\n+      .groupByKey()\n+      .map {\n+        case ((treeIndex, leafIndex), instances) =>\n+          val target = Monoid.sum(instances.map { _.target })\n+          val leaf = LeafNode[K, V, T](0, target)\n+          val expanded = Tree.expand(times, leaf, splitter, evaluator, stopper, instances)\n+          treeIndex -> List(leafIndex -> expanded)\n+      }\n+      .union(emptyExpansions)\n+      .sumByKey\n+      .map {\n+        case (treeIndex, leafExpansions) =>\n+          val expansions = leafExpansions.toMap\n+          val newTree = treeMap(treeIndex)\n+            .updateByLeafIndex(expansions.get(_))\n+          treeIndex -> newTree\n+      }\n+      .cache()\n+\n+    copy(trees = newTrees)\n+  }\n+\n+  def expandTimes(times: Int)(implicit splitter: Splitter[V, T], evaluator: Evaluator[V, T], stopper: Stopper[T]): Trainer[K, V, T] = {\n+    def loop(trainer: Trainer[K, V, T], i: Int): Trainer[K, V, T] =\n+      if (i > 0) loop(trainer.expand, i - 1)\n+      else trainer\n+\n+    loop(updateTargets, times)\n+  }\n+\n+  /**\n+   * Grow the trees by splitting all the leaf nodes as the stopper allows.\n+   */\n+  private def expand(implicit splitter: Splitter[V, T], evaluator: Evaluator[V, T], stopper: Stopper[T]): Trainer[K, V, T] = {\n+    // Our bucket has a tree index, leaf index, and feature.\n+    type Bucket = (Int, Int, K)\n+\n+    // A scored split for a particular feature.\n+    type ScoredSplit = (K, Split[V, T], Double)\n+\n+    implicit object ScoredSplitSemigroup extends Semigroup[ScoredSplit] {\n+      def plus(a: ScoredSplit, b: ScoredSplit) =\n+        if (b._3 > a._3) b else a\n+    }\n+\n+    val treeMap: scala.collection.Map[Int, Tree[K, V, T]] = trees.collectAsMap()\n+\n+    val collectFeatures: Instance[K, V, T] => Iterable[(Bucket, splitter.S)] = { instance =>\n+      val features = instance.features.mapValues(splitter.create(_, instance.target))\n+      for {\n+        (treeIndex, tree) <- treeMap.toList\n+        repetition = sampler.timesInTrainingSet(instance.id, instance.timestamp, treeIndex)\n+        i <- 1 to repetition\n+        leaf <- tree.leafFor(instance.features).toList\n+        if stopper.shouldSplit(leaf.target) && stopper.shouldSplitDistributed(leaf.target)\n+        (feature, stats) <- features\n+        if sampler.includeFeature(feature, treeIndex, leaf.index)\n+      } yield {\n+        (treeIndex, leaf.index, feature) -> stats\n+      }\n+    }\n+\n+    val split: (Bucket, splitter.S) => Iterable[(Int, Map[Int, ScoredSplit])] = { (bucket, stats) =>\n+      val (treeIndex, leafIndex, feature) = bucket\n+      for {\n+        leaf <- treeMap(treeIndex).leafAt(leafIndex).toList\n+        rawSplit <- splitter.split(leaf.target, stats)\n+      } yield {\n+        val (split, goodness) = evaluator.evaluate(rawSplit)\n+        treeIndex -> Map(leafIndex -> (feature, split, goodness))\n+      }\n+    }\n+\n+    val emptySplits: RDD[(Int, Map[Int, ScoredSplit])] =\n+      context.parallelize(Seq.tabulate(sampler.numTrees)(_ -> Map.empty[Int, ScoredSplit]))\n+\n+    val growTree: (Int, Map[Int, ScoredSplit]) => (Int, Tree[K, V, T]) = { (treeIndex, leafSplits) =>\n+      val newTree =\n+        treeMap(treeIndex)\n+          .growByLeafIndex { index =>\n+            for {\n+              (feature, split, _) <- leafSplits.get(index).toList\n+              (predicate, target) <- split.predicates\n+            } yield {\n+              (feature, predicate, target)\n+            }\n+          }\n+      treeIndex -> newTree\n+    }\n+\n+    // Ugh. We could also wrap splitter.S in some box...\n+    implicit val existentialClassTag: ClassTag[splitter.S] =\n+      scala.reflect.classTag[AnyRef].asInstanceOf[ClassTag[splitter.S]]\n+\n+    val newTrees = trainingData\n+      .flatMap(collectFeatures)\n+      .reduceByKey(splitter.semigroup.plus(_, _))\n+      .flatMap(split.tupled)\n+      .union(emptySplits)\n+      .sumByKey\n+      .map(growTree.tupled)\n+      .cache()\n+\n+    copy(trees = newTrees)\n+  }\n+}\n+\n+object Trainer {\n+  val MaxParallelism = 20\n+\n+  def apply[K: Ordering, V, T: Monoid: ClassTag](trainingData: RDD[Instance[K, V, T]], sampler: Sampler[K]): Trainer[K, V, T] = {\n+    val sc = trainingData.context\n+    val initialTrees = Vector.tabulate(sampler.numTrees) { _ -> Tree.empty[K, V, T](Monoid.zero) }\n+    val parallelism = scala.math.min(MaxParallelism, sampler.numTrees)\n+    Trainer(\n+      trainingData,\n+      sampler,\n+      sc.parallelize(initialTrees, parallelism))\n+  }\n+\n+  implicit class ExtraRDDOps[A](val rdd: RDD[A]) extends AnyVal {\n+    def sumByKey[K, V](implicit ev: A <:< (K, V), ctK: ClassTag[K], ctV: ClassTag[V], V: Monoid[V]): RDD[(K, V)] =\n+      rdd.asInstanceOf[RDD[(K, V)]].foldByKey(V.zero)(V.plus)","path":"brushfire-spark/src/main/scala/com/stripe/brushfire/spark/Trainer.scala","position":224,"original_position":224,"commit_id":"33585c7f0de7a8b136c711cde9fbd3c4ab4498e4","original_commit_id":"33585c7f0de7a8b136c711cde9fbd3c4ab4498e4","user":{"login":"tixxit","id":120314,"avatar_url":"https://avatars.githubusercontent.com/u/120314?v=3","gravatar_id":"","url":"https://api.github.com/users/tixxit","html_url":"https://github.com/tixxit","followers_url":"https://api.github.com/users/tixxit/followers","following_url":"https://api.github.com/users/tixxit/following{/other_user}","gists_url":"https://api.github.com/users/tixxit/gists{/gist_id}","starred_url":"https://api.github.com/users/tixxit/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/tixxit/subscriptions","organizations_url":"https://api.github.com/users/tixxit/orgs","repos_url":"https://api.github.com/users/tixxit/repos","events_url":"https://api.github.com/users/tixxit/events{/privacy}","received_events_url":"https://api.github.com/users/tixxit/received_events","type":"User","site_admin":false},"body":"I also just realized that the zero doesn't even really matter here, since the keys are sourced from `rdd`, so at least on `V` must exist anyways. I'll switch the `Monoid` to `Semigroup` and use `reduceByKey`.","created_at":"2015-03-11T17:06:41Z","updated_at":"2015-03-11T17:06:41Z","html_url":"https://github.com/stripe/brushfire/pull/38#discussion_r26232310","pull_request_url":"https://api.github.com/repos/stripe/brushfire/pulls/38","_links":{"self":{"href":"https://api.github.com/repos/stripe/brushfire/pulls/comments/26232310"},"html":{"href":"https://github.com/stripe/brushfire/pull/38#discussion_r26232310"},"pull_request":{"href":"https://api.github.com/repos/stripe/brushfire/pulls/38"}}},"pull_request":{"url":"https://api.github.com/repos/stripe/brushfire/pulls/38","id":30525364,"html_url":"https://github.com/stripe/brushfire/pull/38","diff_url":"https://github.com/stripe/brushfire/pull/38.diff","patch_url":"https://github.com/stripe/brushfire/pull/38.patch","issue_url":"https://api.github.com/repos/stripe/brushfire/issues/38","number":38,"state":"open","locked":false,"title":"Initial Spark Support (Port of Scalding)","user":{"login":"tixxit","id":120314,"avatar_url":"https://avatars.githubusercontent.com/u/120314?v=3","gravatar_id":"","url":"https://api.github.com/users/tixxit","html_url":"https://github.com/tixxit","followers_url":"https://api.github.com/users/tixxit/followers","following_url":"https://api.github.com/users/tixxit/following{/other_user}","gists_url":"https://api.github.com/users/tixxit/gists{/gist_id}","starred_url":"https://api.github.com/users/tixxit/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/tixxit/subscriptions","organizations_url":"https://api.github.com/users/tixxit/orgs","repos_url":"https://api.github.com/users/tixxit/repos","events_url":"https://api.github.com/users/tixxit/events{/privacy}","received_events_url":"https://api.github.com/users/tixxit/received_events","type":"User","site_admin":false},"body":"This isn't merge ready yet, but since I know a few other people are interested (eg @non), I figured I'd make the PR now so I can get some more eyes on it.\r\n\r\nA few thing worth mentioning:\r\n\r\n  * Spark doesn't really have support for something like `Execution`, so we just use its strict `Unit`-happy API everywhere.\r\n  * We only write to files when explicitly asked and use `.cache()` for memoizing the trees in-between steps.\r\n  * Basically just implements `expandTimes` and `expandInMemory`, but not `prune` or `validate` (not hard, just haven't done it).","created_at":"2015-03-05T02:34:49Z","updated_at":"2015-03-11T17:06:41Z","closed_at":null,"merged_at":null,"merge_commit_sha":"301ffcc391bf4213762efbbe07b7208ad36925dd","assignee":null,"milestone":null,"commits_url":"https://api.github.com/repos/stripe/brushfire/pulls/38/commits","review_comments_url":"https://api.github.com/repos/stripe/brushfire/pulls/38/comments","review_comment_url":"https://api.github.com/repos/stripe/brushfire/pulls/comments{/number}","comments_url":"https://api.github.com/repos/stripe/brushfire/issues/38/comments","statuses_url":"https://api.github.com/repos/stripe/brushfire/statuses/33585c7f0de7a8b136c711cde9fbd3c4ab4498e4","head":{"label":"tixxit:topic/initial-spark-support","ref":"topic/initial-spark-support","sha":"33585c7f0de7a8b136c711cde9fbd3c4ab4498e4","user":{"login":"tixxit","id":120314,"avatar_url":"https://avatars.githubusercontent.com/u/120314?v=3","gravatar_id":"","url":"https://api.github.com/users/tixxit","html_url":"https://github.com/tixxit","followers_url":"https://api.github.com/users/tixxit/followers","following_url":"https://api.github.com/users/tixxit/following{/other_user}","gists_url":"https://api.github.com/users/tixxit/gists{/gist_id}","starred_url":"https://api.github.com/users/tixxit/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/tixxit/subscriptions","organizations_url":"https://api.github.com/users/tixxit/orgs","repos_url":"https://api.github.com/users/tixxit/repos","events_url":"https://api.github.com/users/tixxit/events{/privacy}","received_events_url":"https://api.github.com/users/tixxit/received_events","type":"User","site_admin":false},"repo":{"id":31567150,"name":"brushfire","full_name":"tixxit/brushfire","owner":{"login":"tixxit","id":120314,"avatar_url":"https://avatars.githubusercontent.com/u/120314?v=3","gravatar_id":"","url":"https://api.github.com/users/tixxit","html_url":"https://github.com/tixxit","followers_url":"https://api.github.com/users/tixxit/followers","following_url":"https://api.github.com/users/tixxit/following{/other_user}","gists_url":"https://api.github.com/users/tixxit/gists{/gist_id}","starred_url":"https://api.github.com/users/tixxit/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/tixxit/subscriptions","organizations_url":"https://api.github.com/users/tixxit/orgs","repos_url":"https://api.github.com/users/tixxit/repos","events_url":"https://api.github.com/users/tixxit/events{/privacy}","received_events_url":"https://api.github.com/users/tixxit/received_events","type":"User","site_admin":false},"private":false,"html_url":"https://github.com/tixxit/brushfire","description":"Distributed decision tree ensemble learning in Scala","fork":true,"url":"https://api.github.com/repos/tixxit/brushfire","forks_url":"https://api.github.com/repos/tixxit/brushfire/forks","keys_url":"https://api.github.com/repos/tixxit/brushfire/keys{/key_id}","collaborators_url":"https://api.github.com/repos/tixxit/brushfire/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/tixxit/brushfire/teams","hooks_url":"https://api.github.com/repos/tixxit/brushfire/hooks","issue_events_url":"https://api.github.com/repos/tixxit/brushfire/issues/events{/number}","events_url":"https://api.github.com/repos/tixxit/brushfire/events","assignees_url":"https://api.github.com/repos/tixxit/brushfire/assignees{/user}","branches_url":"https://api.github.com/repos/tixxit/brushfire/branches{/branch}","tags_url":"https://api.github.com/repos/tixxit/brushfire/tags","blobs_url":"https://api.github.com/repos/tixxit/brushfire/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/tixxit/brushfire/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/tixxit/brushfire/git/refs{/sha}","trees_url":"https://api.github.com/repos/tixxit/brushfire/git/trees{/sha}","statuses_url":"https://api.github.com/repos/tixxit/brushfire/statuses/{sha}","languages_url":"https://api.github.com/repos/tixxit/brushfire/languages","stargazers_url":"https://api.github.com/repos/tixxit/brushfire/stargazers","contributors_url":"https://api.github.com/repos/tixxit/brushfire/contributors","subscribers_url":"https://api.github.com/repos/tixxit/brushfire/subscribers","subscription_url":"https://api.github.com/repos/tixxit/brushfire/subscription","commits_url":"https://api.github.com/repos/tixxit/brushfire/commits{/sha}","git_commits_url":"https://api.github.com/repos/tixxit/brushfire/git/commits{/sha}","comments_url":"https://api.github.com/repos/tixxit/brushfire/comments{/number}","issue_comment_url":"https://api.github.com/repos/tixxit/brushfire/issues/comments{/number}","contents_url":"https://api.github.com/repos/tixxit/brushfire/contents/{+path}","compare_url":"https://api.github.com/repos/tixxit/brushfire/compare/{base}...{head}","merges_url":"https://api.github.com/repos/tixxit/brushfire/merges","archive_url":"https://api.github.com/repos/tixxit/brushfire/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/tixxit/brushfire/downloads","issues_url":"https://api.github.com/repos/tixxit/brushfire/issues{/number}","pulls_url":"https://api.github.com/repos/tixxit/brushfire/pulls{/number}","milestones_url":"https://api.github.com/repos/tixxit/brushfire/milestones{/number}","notifications_url":"https://api.github.com/repos/tixxit/brushfire/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/tixxit/brushfire/labels{/name}","releases_url":"https://api.github.com/repos/tixxit/brushfire/releases{/id}","created_at":"2015-03-02T22:36:37Z","updated_at":"2015-03-02T22:36:38Z","pushed_at":"2015-03-05T02:30:46Z","git_url":"git://github.com/tixxit/brushfire.git","ssh_url":"git@github.com:tixxit/brushfire.git","clone_url":"https://github.com/tixxit/brushfire.git","svn_url":"https://github.com/tixxit/brushfire","homepage":null,"size":1285,"stargazers_count":0,"watchers_count":0,"language":"Scala","has_issues":false,"has_downloads":true,"has_wiki":true,"has_pages":true,"forks_count":0,"mirror_url":null,"open_issues_count":0,"forks":0,"open_issues":0,"watchers":0,"default_branch":"master"}},"base":{"label":"stripe:master","ref":"master","sha":"7a0dea9808f3f876cfa4dce7f8d45282522cc21d","user":{"login":"stripe","id":856813,"avatar_url":"https://avatars.githubusercontent.com/u/856813?v=3","gravatar_id":"","url":"https://api.github.com/users/stripe","html_url":"https://github.com/stripe","followers_url":"https://api.github.com/users/stripe/followers","following_url":"https://api.github.com/users/stripe/following{/other_user}","gists_url":"https://api.github.com/users/stripe/gists{/gist_id}","starred_url":"https://api.github.com/users/stripe/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/stripe/subscriptions","organizations_url":"https://api.github.com/users/stripe/orgs","repos_url":"https://api.github.com/users/stripe/repos","events_url":"https://api.github.com/users/stripe/events{/privacy}","received_events_url":"https://api.github.com/users/stripe/received_events","type":"Organization","site_admin":false},"repo":{"id":26926567,"name":"brushfire","full_name":"stripe/brushfire","owner":{"login":"stripe","id":856813,"avatar_url":"https://avatars.githubusercontent.com/u/856813?v=3","gravatar_id":"","url":"https://api.github.com/users/stripe","html_url":"https://github.com/stripe","followers_url":"https://api.github.com/users/stripe/followers","following_url":"https://api.github.com/users/stripe/following{/other_user}","gists_url":"https://api.github.com/users/stripe/gists{/gist_id}","starred_url":"https://api.github.com/users/stripe/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/stripe/subscriptions","organizations_url":"https://api.github.com/users/stripe/orgs","repos_url":"https://api.github.com/users/stripe/repos","events_url":"https://api.github.com/users/stripe/events{/privacy}","received_events_url":"https://api.github.com/users/stripe/received_events","type":"Organization","site_admin":false},"private":false,"html_url":"https://github.com/stripe/brushfire","description":"Distributed decision tree ensemble learning in Scala","fork":false,"url":"https://api.github.com/repos/stripe/brushfire","forks_url":"https://api.github.com/repos/stripe/brushfire/forks","keys_url":"https://api.github.com/repos/stripe/brushfire/keys{/key_id}","collaborators_url":"https://api.github.com/repos/stripe/brushfire/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/stripe/brushfire/teams","hooks_url":"https://api.github.com/repos/stripe/brushfire/hooks","issue_events_url":"https://api.github.com/repos/stripe/brushfire/issues/events{/number}","events_url":"https://api.github.com/repos/stripe/brushfire/events","assignees_url":"https://api.github.com/repos/stripe/brushfire/assignees{/user}","branches_url":"https://api.github.com/repos/stripe/brushfire/branches{/branch}","tags_url":"https://api.github.com/repos/stripe/brushfire/tags","blobs_url":"https://api.github.com/repos/stripe/brushfire/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/stripe/brushfire/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/stripe/brushfire/git/refs{/sha}","trees_url":"https://api.github.com/repos/stripe/brushfire/git/trees{/sha}","statuses_url":"https://api.github.com/repos/stripe/brushfire/statuses/{sha}","languages_url":"https://api.github.com/repos/stripe/brushfire/languages","stargazers_url":"https://api.github.com/repos/stripe/brushfire/stargazers","contributors_url":"https://api.github.com/repos/stripe/brushfire/contributors","subscribers_url":"https://api.github.com/repos/stripe/brushfire/subscribers","subscription_url":"https://api.github.com/repos/stripe/brushfire/subscription","commits_url":"https://api.github.com/repos/stripe/brushfire/commits{/sha}","git_commits_url":"https://api.github.com/repos/stripe/brushfire/git/commits{/sha}","comments_url":"https://api.github.com/repos/stripe/brushfire/comments{/number}","issue_comment_url":"https://api.github.com/repos/stripe/brushfire/issues/comments{/number}","contents_url":"https://api.github.com/repos/stripe/brushfire/contents/{+path}","compare_url":"https://api.github.com/repos/stripe/brushfire/compare/{base}...{head}","merges_url":"https://api.github.com/repos/stripe/brushfire/merges","archive_url":"https://api.github.com/repos/stripe/brushfire/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/stripe/brushfire/downloads","issues_url":"https://api.github.com/repos/stripe/brushfire/issues{/number}","pulls_url":"https://api.github.com/repos/stripe/brushfire/pulls{/number}","milestones_url":"https://api.github.com/repos/stripe/brushfire/milestones{/number}","notifications_url":"https://api.github.com/repos/stripe/brushfire/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/stripe/brushfire/labels{/name}","releases_url":"https://api.github.com/repos/stripe/brushfire/releases{/id}","created_at":"2014-11-20T18:47:07Z","updated_at":"2015-03-10T09:51:23Z","pushed_at":"2015-03-02T20:41:52Z","git_url":"git://github.com/stripe/brushfire.git","ssh_url":"git@github.com:stripe/brushfire.git","clone_url":"https://github.com/stripe/brushfire.git","svn_url":"https://github.com/stripe/brushfire","homepage":null,"size":2260,"stargazers_count":135,"watchers_count":135,"language":"Scala","has_issues":true,"has_downloads":true,"has_wiki":true,"has_pages":true,"forks_count":17,"mirror_url":null,"open_issues_count":16,"forks":17,"open_issues":16,"watchers":135,"default_branch":"master"}},"_links":{"self":{"href":"https://api.github.com/repos/stripe/brushfire/pulls/38"},"html":{"href":"https://github.com/stripe/brushfire/pull/38"},"issue":{"href":"https://api.github.com/repos/stripe/brushfire/issues/38"},"comments":{"href":"https://api.github.com/repos/stripe/brushfire/issues/38/comments"},"review_comments":{"href":"https://api.github.com/repos/stripe/brushfire/pulls/38/comments"},"review_comment":{"href":"https://api.github.com/repos/stripe/brushfire/pulls/comments{/number}"},"commits":{"href":"https://api.github.com/repos/stripe/brushfire/pulls/38/commits"},"statuses":{"href":"https://api.github.com/repos/stripe/brushfire/statuses/33585c7f0de7a8b136c711cde9fbd3c4ab4498e4"}}}}